1#![allow(
11 clippy::too_many_lines,
12 clippy::too_many_arguments,
13 reason = "faithful move of the health pipeline from fallow-cli; structure preserved verbatim"
14)]
15#![allow(
16 clippy::print_stderr,
17 reason = "human stderr notes (coverage, churn, baseline) preserved verbatim from the CLI health path"
18)]
19#![allow(
20 clippy::redundant_pub_crate,
21 reason = "pub(crate) marks items the sibling health submodules consume across the private execute module"
22)]
23
24use std::process::ExitCode;
25use std::time::{Duration, Instant};
26
27use colored::Colorize;
28use fallow_config::{OutputFormat, PackageJson, ResolvedConfig, Severity};
29
30use crate::baseline::{HealthBaselineData, filter_new_health_findings, filter_new_health_targets};
31use crate::error::emit_error;
32use crate::vital_signs;
33use fallow_output::*;
34
35use super::{HealthExecutionOptions, HealthSeams, HealthSort, RuntimeCoverageSeamInput};
36
37use super::{grouping, hotspots, react_hooks, scoring, tailwind_theme, targets};
38
39use super::assembly::assemble_health_report;
40use super::hotspots::compute_hotspots;
41use super::runtime_filter::{
42 RuntimeCoverageFilterContext, apply_runtime_coverage_filters, relative_to_root,
43};
44use super::scoring::compute_file_scores;
45use super::targets::{TargetAuxData, compute_refactoring_targets};
46
47pub type HealthOptions<'a> = HealthExecutionOptions<'a>;
48
49pub type HealthResultGeneric<R> = super::HealthAnalysisResult<R>;
51
52pub struct HealthPipelineInputs {
54 pub config: ResolvedConfig,
55 pub files: Vec<fallow_types::discover::DiscoveredFile>,
56 pub modules: Vec<fallow_types::extract::ModuleInfo>,
57 pub config_ms: f64,
59 pub discover_ms: f64,
60 pub parse_ms: f64,
61 pub parse_cpu_ms: f64,
62 pub shared_parse: bool,
64 pub pre_computed_analysis: Option<crate::DeadCodeAnalysisArtifacts>,
65}
66
67pub struct HealthScopeInputs<'a, R> {
74 pub changed_files: Option<rustc_hash::FxHashSet<std::path::PathBuf>>,
75 pub diff_index: Option<&'a fallow_output::DiffIndex>,
76 pub ws_roots: Option<Vec<std::path::PathBuf>>,
77 pub group_resolver: Option<R>,
78}
79
80struct HealthPipelineTimings {
81 config: f64,
82 discover: f64,
83 parse: f64,
84 parse_cpu: f64,
86 shared_parse: bool,
88}
89
90impl HealthPipelineTimings {
91 fn into_base_input(self, complexity_ms: f64) -> HealthTimingBaseInput {
92 HealthTimingBaseInput {
93 config_ms: self.config,
94 discover_ms: self.discover,
95 parse_ms: self.parse,
96 parse_cpu_ms: self.parse_cpu,
97 complexity_ms,
98 shared_parse: self.shared_parse,
99 }
100 }
101}
102
103struct HealthScope<'a, R> {
104 max_cyclomatic: u16,
105 max_cognitive: u16,
106 max_crap: f64,
107 enforce_crap: bool,
108 ignore_set: globset::GlobSet,
109 changed_files: Option<rustc_hash::FxHashSet<std::path::PathBuf>>,
110 diff_index: Option<&'a fallow_output::DiffIndex>,
111 ws_roots: Option<Vec<std::path::PathBuf>>,
112 group_resolver: Option<R>,
113 file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
114}
115
116fn validate_churn_file(opts: &HealthOptions<'_>) -> Result<(), ExitCode> {
128 if let Some(churn_file) = opts.churn_file
129 && (opts.hotspots || opts.targets)
130 {
131 let resolved = scoring::resolve_relative_to_root(churn_file, Some(opts.root));
132 crate::churn::analyze_churn_from_file(&resolved, opts.root)
133 .map_err(|e| emit_error(&e, 2, opts.output))?;
134 }
135 Ok(())
136}
137
138pub fn validate_health_churn_file(opts: &HealthOptions<'_>) -> Result<(), ExitCode> {
141 validate_churn_file(opts)
142}
143
144pub fn execute_health_inner<'a, R: super::HealthGroupResolver>(
156 opts: &HealthOptions<'a>,
157 input: HealthPipelineInputs,
158 scope_inputs: HealthScopeInputs<'a, R>,
159 seams: &HealthSeams<'_>,
160) -> Result<HealthResultGeneric<R>, ExitCode> {
161 let start = Instant::now();
162 let HealthPipelineInputs {
163 config,
164 files,
165 modules,
166 config_ms,
167 discover_ms,
168 parse_ms,
169 parse_cpu_ms,
170 shared_parse,
171 pre_computed_analysis,
172 } = input;
173 let timings = HealthPipelineTimings {
174 config: config_ms,
175 discover: discover_ms,
176 parse: parse_ms,
177 parse_cpu: parse_cpu_ms,
178 shared_parse,
179 };
180
181 let scope = prepare_health_scope(opts, &config, &files, scope_inputs);
182
183 let HealthPreparedCore {
184 findings_data,
185 analysis_data,
186 derived_sections,
187 vital_data,
188 report_coverage_gaps,
189 enforce_coverage_gaps,
190 has_istanbul_coverage,
191 needs_file_scores,
192 } = prepare_health_core_sections(HealthCoreSectionsInput {
193 opts,
194 config: &config,
195 files: &files,
196 modules: &modules,
197 scope: &scope,
198 pre_computed_analysis,
199 seams,
200 })?;
201
202 let HealthOutputContext { build, sections } =
203 prepare_health_output_context(HealthOutputContextInput {
204 config: &config,
205 files: &files,
206 modules: &modules,
207 scope: &scope,
208 needs_file_scores,
209 report_coverage_gaps,
210 has_istanbul_coverage,
211 findings_data,
212 analysis_data,
213 derived_sections,
214 vital_data,
215 timings,
216 start: &start,
217 });
218
219 let output = build_health_output_parts(opts, &build, sections);
220
221 Ok(finalize_health_result(HealthFinalizeInput {
222 opts,
223 config,
224 files: &files,
225 scope,
226 output,
227 elapsed: start.elapsed(),
228 should_fail_on_coverage_gaps: enforce_coverage_gaps,
229 }))
230}
231
232struct HealthCoreSectionsInput<'a, R> {
233 opts: &'a HealthOptions<'a>,
234 config: &'a ResolvedConfig,
235 files: &'a [fallow_types::discover::DiscoveredFile],
236 modules: &'a [crate::extract::ModuleInfo],
237 scope: &'a HealthScope<'a, R>,
238 pre_computed_analysis: Option<crate::DeadCodeAnalysisArtifacts>,
239 seams: &'a HealthSeams<'a>,
240}
241
242struct HealthAnalysisPreludeInput<'a, R> {
243 opts: &'a HealthOptions<'a>,
244 config: &'a ResolvedConfig,
245 modules: &'a [crate::extract::ModuleInfo],
246 scope: &'a HealthScope<'a, R>,
247 pre_computed_analysis: Option<crate::DeadCodeAnalysisArtifacts>,
248 seams: &'a HealthSeams<'a>,
249}
250
251struct HealthScopedFindingsInput<'a, R> {
252 opts: &'a HealthOptions<'a>,
253 config: &'a ResolvedConfig,
254 modules: &'a [crate::extract::ModuleInfo],
255 scope: &'a HealthScope<'a, R>,
256 score_output: Option<&'a scoring::FileScoreOutput>,
257}
258
259struct HealthAnalysisPrelude {
260 analysis_data: HealthAnalysisData,
261 report_coverage_gaps: bool,
262 enforce_coverage_gaps: bool,
263 has_istanbul_coverage: bool,
264 needs_file_scores: bool,
265}
266
267struct HealthPreparedCore {
268 findings_data: HealthFindingsData,
269 analysis_data: HealthAnalysisData,
270 derived_sections: HealthDerivedSections,
271 vital_data: HealthVitalData,
272 report_coverage_gaps: bool,
273 enforce_coverage_gaps: bool,
274 has_istanbul_coverage: bool,
275 needs_file_scores: bool,
276}
277
278fn prepare_health_analysis_prelude<R>(
279 input: HealthAnalysisPreludeInput<'_, R>,
280) -> Result<HealthAnalysisPrelude, ExitCode> {
281 let HealthCoverageSettings {
282 report_coverage_gaps,
283 enforce_coverage_gaps,
284 istanbul_coverage,
285 } = prepare_health_coverage_settings(input.opts, input.config)?;
286
287 let needs_file_scores = needs_health_file_scores(
288 input.opts,
289 report_coverage_gaps,
290 enforce_coverage_gaps,
291 input.scope.enforce_crap,
292 );
293 let analysis_data = prepare_health_analysis_data(HealthAnalysisDataInput {
294 opts: input.opts,
295 config: input.config,
296 modules: input.modules,
297 file_paths: &input.scope.file_paths,
298 ignore_set: &input.scope.ignore_set,
299 changed_files: input.scope.changed_files.as_ref(),
300 ws_roots: input.scope.ws_roots.as_deref(),
301 istanbul_coverage: istanbul_coverage.as_ref(),
302 pre_computed_analysis: input.pre_computed_analysis,
303 needs_file_scores,
304 seams: input.seams,
305 })?;
306
307 Ok(HealthAnalysisPrelude {
308 analysis_data,
309 report_coverage_gaps,
310 enforce_coverage_gaps,
311 has_istanbul_coverage: istanbul_coverage.is_some(),
312 needs_file_scores,
313 })
314}
315
316fn prepare_health_scoped_findings<R>(
317 input: &HealthScopedFindingsInput<'_, R>,
318) -> Result<HealthFindingsData, ExitCode> {
319 prepare_health_findings(HealthFindingsInput {
320 opts: input.opts,
321 config: input.config,
322 modules: input.modules,
323 file_paths: &input.scope.file_paths,
324 ignore_set: &input.scope.ignore_set,
325 changed_files: input.scope.changed_files.as_ref(),
326 ws_roots: input.scope.ws_roots.as_deref(),
327 diff_index: input.scope.diff_index,
328 max_cyclomatic: input.scope.max_cyclomatic,
329 max_cognitive: input.scope.max_cognitive,
330 max_crap: input.scope.max_crap,
331 enforce_crap: input.scope.enforce_crap,
332 score_output: input.score_output,
333 })
334}
335
336fn prepare_health_core_sections<R>(
337 input: HealthCoreSectionsInput<'_, R>,
338) -> Result<HealthPreparedCore, ExitCode> {
339 let HealthCoreSectionsInput {
340 opts,
341 config,
342 files,
343 modules,
344 scope,
345 pre_computed_analysis,
346 seams,
347 } = input;
348
349 let HealthAnalysisPrelude {
350 analysis_data,
351 report_coverage_gaps,
352 enforce_coverage_gaps,
353 has_istanbul_coverage,
354 needs_file_scores,
355 } = prepare_health_analysis_prelude(HealthAnalysisPreludeInput {
356 opts,
357 config,
358 modules,
359 scope,
360 pre_computed_analysis,
361 seams,
362 })?;
363
364 let findings_data = prepare_health_scoped_findings(&HealthScopedFindingsInput {
365 opts,
366 config,
367 modules,
368 scope,
369 score_output: analysis_data.score_output.as_ref(),
370 })?;
371
372 let HealthRuntimeSections {
373 analysis_data,
374 derived_sections,
375 vital_data,
376 } = prepare_health_runtime_sections(
377 opts,
378 HealthRuntimeSectionsInput {
379 config,
380 files,
381 modules,
382 file_paths: &scope.file_paths,
383 ignore_set: &scope.ignore_set,
384 changed_files: scope.changed_files.as_ref(),
385 ws_roots: scope.ws_roots.as_deref(),
386 diff_index: scope.diff_index,
387 loaded_baseline: findings_data.loaded_baseline.as_ref(),
388 findings: &findings_data.findings,
389 analysis_data,
390 has_istanbul_coverage,
391 needs_file_scores,
392 },
393 )?;
394
395 Ok(HealthPreparedCore {
396 findings_data,
397 analysis_data,
398 derived_sections,
399 vital_data,
400 report_coverage_gaps,
401 enforce_coverage_gaps,
402 has_istanbul_coverage,
403 needs_file_scores,
404 })
405}
406
407#[derive(Clone, Copy)]
412struct HealthScanCtx<'a> {
413 config: &'a ResolvedConfig,
414 ignore_set: &'a globset::GlobSet,
415 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
416 ws_roots: Option<&'a [std::path::PathBuf]>,
417}
418
419struct HealthReportSideEffectsInput<'a> {
420 opts: &'a HealthOptions<'a>,
421 report: &'a mut fallow_output::HealthReport,
422 files: &'a [fallow_types::discover::DiscoveredFile],
423 config: &'a ResolvedConfig,
424 ignore_set: &'a globset::GlobSet,
425 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
426 ws_roots: Option<&'a [std::path::PathBuf]>,
427}
428
429struct HealthFinalizeInput<'a, R> {
430 opts: &'a HealthOptions<'a>,
431 config: ResolvedConfig,
432 files: &'a [fallow_types::discover::DiscoveredFile],
433 scope: HealthScope<'a, R>,
434 output: HealthOutputParts,
435 elapsed: Duration,
436 should_fail_on_coverage_gaps: bool,
437}
438
439fn finalize_health_report_side_effects(input: &mut HealthReportSideEffectsInput<'_>) {
440 if input.opts.css {
441 input.report.css_analytics = compute_css_analytics_report(
442 input.files,
443 HealthScanCtx {
444 config: input.config,
445 ignore_set: input.ignore_set,
446 changed_files: input.changed_files,
447 ws_roots: input.ws_roots,
448 },
449 );
450 }
451}
452
453fn finalize_health_result<R>(input: HealthFinalizeInput<'_, R>) -> HealthResultGeneric<R> {
454 let HealthFinalizeInput {
455 opts,
456 config,
457 files,
458 scope,
459 output,
460 elapsed,
461 should_fail_on_coverage_gaps,
462 } = input;
463 let HealthOutputParts {
464 mut report,
465 grouping,
466 timings,
467 coverage_gaps_has_findings,
468 } = output;
469
470 finalize_health_report_side_effects(&mut HealthReportSideEffectsInput {
471 opts,
472 report: &mut report,
473 files,
474 config: &config,
475 ignore_set: &scope.ignore_set,
476 changed_files: scope.changed_files.as_ref(),
477 ws_roots: scope.ws_roots.as_deref(),
478 });
479
480 build_health_result(HealthResultInput {
481 config,
482 report,
483 grouping,
484 group_resolver: scope.group_resolver,
485 elapsed,
486 timings,
487 coverage_gaps_has_findings,
488 should_fail_on_coverage_gaps,
489 })
490}
491
492#[derive(Default)]
505struct CssTokenSets {
506 colors: rustc_hash::FxHashSet<String>,
507 font_sizes: rustc_hash::FxHashSet<String>,
508 z_indexes: rustc_hash::FxHashSet<String>,
509 box_shadows: rustc_hash::FxHashSet<String>,
510 border_radii: rustc_hash::FxHashSet<String>,
511 line_heights: rustc_hash::FxHashSet<String>,
512 defined_custom_props: rustc_hash::FxHashSet<String>,
513 referenced_custom_props: rustc_hash::FxHashSet<String>,
514 defined_keyframes: rustc_hash::FxHashSet<String>,
515 referenced_keyframes: rustc_hash::FxHashSet<String>,
516 keyframes_definers: rustc_hash::FxHashMap<String, String>,
517 keyframe_referencers: rustc_hash::FxHashMap<String, String>,
518 declaration_blocks: rustc_hash::FxHashMap<u64, (u16, Vec<(String, u32)>)>,
521 registered_custom_props: rustc_hash::FxHashSet<String>,
524 declared_layers: rustc_hash::FxHashSet<String>,
525 populated_layers: rustc_hash::FxHashSet<String>,
526 property_registrars: rustc_hash::FxHashMap<String, String>,
527 layer_declarers: rustc_hash::FxHashMap<String, String>,
528 defined_font_faces: rustc_hash::FxHashSet<String>,
531 referenced_font_families: rustc_hash::FxHashSet<String>,
532 font_face_definers: rustc_hash::FxHashMap<String, String>,
533 theme_token_definers: rustc_hash::FxHashMap<String, (String, u32)>,
536 apply_tokens: rustc_hash::FxHashSet<String>,
539 theme_var_reads: rustc_hash::FxHashSet<String>,
544 any_plugin_directive: bool,
549}
550
551impl CssTokenSets {
552 fn group_duplicate_blocks(
556 &self,
557 summary: &mut fallow_output::CssAnalyticsSummary,
558 ) -> Vec<fallow_output::CssDuplicateBlock> {
559 use fallow_output::{CssBlockOccurrence, CssCandidateAction, CssDuplicateBlock};
560
561 let mut groups: Vec<CssDuplicateBlock> = self
562 .declaration_blocks
563 .values()
564 .filter(|(_, occurrences)| occurrences.len() >= 2)
565 .map(|(declaration_count, occurrences)| {
566 let occurrence_count = saturate_len(occurrences.len());
567 let estimated_savings = occurrence_count
568 .saturating_sub(1)
569 .saturating_mul(u32::from(*declaration_count));
570 let mut occ: Vec<CssBlockOccurrence> = occurrences
571 .iter()
572 .map(|(path, line)| CssBlockOccurrence {
573 path: path.clone(),
574 line: *line,
575 })
576 .collect();
577 occ.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
578 CssDuplicateBlock {
579 declaration_count: *declaration_count,
580 occurrence_count,
581 estimated_savings,
582 occurrences: occ,
583 actions: vec![CssCandidateAction::consolidate_block(occurrence_count)],
584 }
585 })
586 .collect();
587 groups.sort_by(|a, b| {
590 b.estimated_savings
591 .cmp(&a.estimated_savings)
592 .then_with(|| occurrence_sort_key(a).cmp(&occurrence_sort_key(b)))
593 });
594 summary.duplicate_declaration_blocks = saturate_len(groups.len());
595 summary.duplicate_declarations_total = groups
596 .iter()
597 .fold(0u32, |acc, g| acc.saturating_add(g.estimated_savings));
598 groups
599 }
600
601 fn record(&mut self, analytics: &fallow_types::extract::CssAnalytics, rel: &str) {
604 self.colors.extend(analytics.colors.iter().cloned());
605 self.font_sizes.extend(analytics.font_sizes.iter().cloned());
606 self.z_indexes.extend(analytics.z_indexes.iter().cloned());
607 self.box_shadows
608 .extend(analytics.box_shadows.iter().cloned());
609 self.border_radii
610 .extend(analytics.border_radii.iter().cloned());
611 self.line_heights
612 .extend(analytics.line_heights.iter().cloned());
613 self.defined_custom_props
614 .extend(analytics.defined_custom_properties.iter().cloned());
615 self.referenced_custom_props
616 .extend(analytics.referenced_custom_properties.iter().cloned());
617 for keyframes in &analytics.referenced_keyframes {
618 self.referenced_keyframes.insert(keyframes.clone());
619 self.keyframe_referencers
620 .entry(keyframes.clone())
621 .or_insert_with(|| rel.to_owned());
622 }
623 for keyframes in &analytics.defined_keyframes {
624 self.defined_keyframes.insert(keyframes.clone());
625 self.keyframes_definers
626 .entry(keyframes.clone())
627 .or_insert_with(|| rel.to_owned());
628 }
629 for block in &analytics.declaration_blocks {
630 self.declaration_blocks
631 .entry(block.fingerprint)
632 .or_insert_with(|| (block.declaration_count, Vec::new()))
633 .1
634 .push((rel.to_owned(), block.line));
635 }
636 for name in &analytics.registered_custom_properties {
637 self.registered_custom_props.insert(name.clone());
638 self.property_registrars
639 .entry(name.clone())
640 .or_insert_with(|| rel.to_owned());
641 }
642 for family in &analytics.referenced_font_families {
643 self.referenced_font_families.insert(family.clone());
644 }
645 for family in &analytics.defined_font_faces {
646 self.defined_font_faces.insert(family.clone());
647 self.font_face_definers
648 .entry(family.clone())
649 .or_insert_with(|| rel.to_owned());
650 }
651 for name in &analytics.populated_layers {
652 self.populated_layers.insert(name.clone());
653 }
654 for name in &analytics.declared_layers {
655 self.declared_layers.insert(name.clone());
656 self.layer_declarers
657 .entry(name.clone())
658 .or_insert_with(|| rel.to_owned());
659 }
660 }
661
662 fn record_theme(&mut self, source: &str, rel: &str) {
668 let scan = crate::extract::scan_theme_blocks(source);
669 for token in scan.tokens {
670 self.theme_token_definers
671 .entry(token.name)
672 .or_insert_with(|| (rel.to_owned(), token.line));
673 }
674 self.theme_var_reads.extend(scan.theme_var_reads);
675 self.apply_tokens
676 .extend(crate::extract::extract_apply_tokens(source));
677 if source.contains("@plugin") {
678 self.any_plugin_directive = true;
679 }
680 }
681
682 fn group_unused_at_rules(
686 &self,
687 summary: &mut fallow_output::CssAnalyticsSummary,
688 ) -> Vec<fallow_output::UnusedAtRule> {
689 use fallow_output::{CssCandidateAction, UnusedAtRule, UnusedAtRuleKind};
690
691 let mut out: Vec<UnusedAtRule> = Vec::new();
692 for name in self
693 .registered_custom_props
694 .difference(&self.referenced_custom_props)
695 {
696 out.push(UnusedAtRule {
697 kind: UnusedAtRuleKind::PropertyRegistration,
698 name: name.clone(),
699 path: self
700 .property_registrars
701 .get(name)
702 .cloned()
703 .unwrap_or_default(),
704 actions: vec![CssCandidateAction::verify_unused_at_rule(
705 UnusedAtRuleKind::PropertyRegistration,
706 name,
707 )],
708 });
709 }
710 summary.unused_property_registrations = saturate_len(out.len());
711 let property_count = out.len();
712 for name in self.declared_layers.difference(&self.populated_layers) {
713 out.push(UnusedAtRule {
714 kind: UnusedAtRuleKind::Layer,
715 name: name.clone(),
716 path: self.layer_declarers.get(name).cloned().unwrap_or_default(),
717 actions: vec![CssCandidateAction::verify_unused_at_rule(
718 UnusedAtRuleKind::Layer,
719 name,
720 )],
721 });
722 }
723 summary.unused_layers = saturate_len(out.len() - property_count);
724 out.sort_by(|a, b| (a.kind as u8, &a.path, &a.name).cmp(&(b.kind as u8, &b.path, &b.name)));
725 out
726 }
727
728 fn finalize(
732 &self,
733 summary: &mut fallow_output::CssAnalyticsSummary,
734 ) -> (
735 Vec<fallow_output::UnreferencedKeyframes>,
736 Vec<fallow_output::UndefinedKeyframes>,
737 ) {
738 use fallow_output::{CssCandidateAction, UndefinedKeyframes, UnreferencedKeyframes};
739
740 summary.unique_colors = saturate_len(self.colors.len());
741 summary.unique_font_sizes = saturate_len(self.font_sizes.len());
742 summary.unique_z_indexes = saturate_len(self.z_indexes.len());
743 summary.unique_box_shadows = saturate_len(self.box_shadows.len());
744 summary.unique_border_radii = saturate_len(self.border_radii.len());
745 summary.unique_line_heights = saturate_len(self.line_heights.len());
746 summary.custom_properties_defined = saturate_len(self.defined_custom_props.len());
747 summary.custom_properties_unreferenced = saturate_len(
748 self.defined_custom_props
749 .difference(&self.referenced_custom_props)
750 .count(),
751 );
752 summary.custom_properties_undefined = saturate_len(
756 self.referenced_custom_props
757 .difference(&self.defined_custom_props)
758 .count(),
759 );
760 summary.keyframes_defined = saturate_len(self.defined_keyframes.len());
761 summary.keyframes_unreferenced = saturate_len(
762 self.defined_keyframes
763 .difference(&self.referenced_keyframes)
764 .count(),
765 );
766 summary.keyframes_undefined = saturate_len(
767 self.referenced_keyframes
768 .difference(&self.defined_keyframes)
769 .count(),
770 );
771
772 let unreferenced_keyframes = locate_keyframe_diff(
775 &self.defined_keyframes,
776 &self.referenced_keyframes,
777 &self.keyframes_definers,
778 )
779 .into_iter()
780 .map(|(name, path)| UnreferencedKeyframes {
781 actions: vec![CssCandidateAction::verify_keyframe(&name)],
782 name,
783 path,
784 })
785 .collect();
786 let undefined_keyframes = locate_keyframe_diff(
787 &self.referenced_keyframes,
788 &self.defined_keyframes,
789 &self.keyframe_referencers,
790 )
791 .into_iter()
792 .map(|(name, path)| UndefinedKeyframes {
793 actions: vec![CssCandidateAction::verify_undefined_keyframe(&name)],
794 name,
795 path,
796 })
797 .collect();
798 (unreferenced_keyframes, undefined_keyframes)
799 }
800
801 fn unused_font_faces(
805 &self,
806 summary: &mut fallow_output::CssAnalyticsSummary,
807 ) -> Vec<fallow_output::UnusedFontFace> {
808 use fallow_output::{CssCandidateAction, UnusedFontFace};
809 let referenced_lower: rustc_hash::FxHashSet<String> = self
814 .referenced_font_families
815 .iter()
816 .map(|family| family.to_ascii_lowercase())
817 .collect();
818 let mut out: Vec<UnusedFontFace> = self
819 .defined_font_faces
820 .iter()
821 .filter(|family| !referenced_lower.contains(&family.to_ascii_lowercase()))
822 .map(|family| UnusedFontFace {
823 actions: vec![CssCandidateAction::verify_unused_font_face(family)],
824 path: self
825 .font_face_definers
826 .get(family)
827 .cloned()
828 .unwrap_or_default(),
829 family: family.clone(),
830 })
831 .collect();
832 out.sort_by(|a, b| (&a.path, &a.family).cmp(&(&b.path, &b.family)));
833 summary.unused_font_faces = saturate_len(out.len());
834 out
835 }
836
837 fn font_size_unit_mix(
843 &self,
844 summary: &mut fallow_output::CssAnalyticsSummary,
845 ) -> Option<fallow_output::CssNotationConsistency> {
846 use fallow_output::{CssCandidateAction, CssNotationConsistency, CssNotationCount};
847
848 let mut counts: rustc_hash::FxHashMap<&'static str, u32> = rustc_hash::FxHashMap::default();
849 for value in &self.font_sizes {
850 if let Some(unit) = classify_font_size_unit(value) {
851 *counts.entry(unit).or_insert(0) += 1;
852 }
853 }
854 summary.font_size_units_used = saturate_len(counts.len());
855
856 let total: u32 = counts.values().copied().sum();
860 if counts.len() < 2 || total < MIN_FONT_SIZE_UNIT_MIX {
861 return None;
862 }
863 let mut notations: Vec<CssNotationCount> = counts
864 .into_iter()
865 .map(|(notation, count)| CssNotationCount {
866 notation: notation.to_owned(),
867 count,
868 })
869 .collect();
870 notations.sort_by(|a, b| {
872 b.count
873 .cmp(&a.count)
874 .then_with(|| a.notation.cmp(&b.notation))
875 });
876 let dominant = notations[0].notation.clone();
878 Some(CssNotationConsistency {
879 actions: vec![CssCandidateAction::standardize_notation(
880 "Font sizes",
881 &dominant,
882 )],
883 axis: "Font sizes".to_owned(),
884 notations,
885 })
886 }
887}
888
889const MIN_FONT_SIZE_UNIT_MIX: u32 = 6;
893
894fn classify_font_size_unit(value: &str) -> Option<&'static str> {
900 let v = value.trim();
901 if v.is_empty() || v.contains('(') {
902 return None;
903 }
904 if let Some(stripped) = v.strip_suffix('%') {
905 return stripped
907 .chars()
908 .all(|c| c.is_ascii_digit() || c == '.')
909 .then_some("%");
910 }
911 let unit_start = v.find(|c: char| c.is_ascii_alphabetic())?;
912 let (number, unit) = v.split_at(unit_start);
913 if number.is_empty()
916 || !number
917 .chars()
918 .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
919 {
920 return None;
921 }
922 match unit.to_ascii_lowercase().as_str() {
923 "px" => Some("px"),
924 "rem" => Some("rem"),
925 "em" => Some("em"),
926 "pt" => Some("pt"),
927 _ => Some("other"),
928 }
929}
930
931fn locate_keyframe_diff(
935 present: &rustc_hash::FxHashSet<String>,
936 absent: &rustc_hash::FxHashSet<String>,
937 locator: &rustc_hash::FxHashMap<String, String>,
938) -> Vec<(String, String)> {
939 let mut out: Vec<(String, String)> = present
940 .difference(absent)
941 .map(|name| (name.clone(), locator.get(name).cloned().unwrap_or_default()))
942 .collect();
943 out.sort_by(|a, b| (&a.1, &a.0).cmp(&(&b.1, &b.0)));
944 out
945}
946
947fn saturate_len(len: usize) -> u32 {
949 u32::try_from(len).unwrap_or(u32::MAX)
950}
951
952fn occurrence_sort_key(block: &fallow_output::CssDuplicateBlock) -> (&str, u32) {
955 block
956 .occurrences
957 .first()
958 .map_or(("", 0), |occ| (occ.path.as_str(), occ.line))
959}
960
961fn project_uses_tailwind(root: &std::path::Path) -> bool {
966 let Ok(text) = std::fs::read_to_string(root.join("package.json")) else {
967 return false;
968 };
969 let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
970 return false;
971 };
972 ["dependencies", "devDependencies", "peerDependencies"]
973 .iter()
974 .any(|key| {
975 json.get(key)
976 .and_then(serde_json::Value::as_object)
977 .is_some_and(|deps| {
978 deps.keys()
979 .any(|k| k == "tailwindcss" || k.starts_with("@tailwindcss/"))
980 })
981 })
982}
983
984fn read_markup_scan_source(
994 file: &fallow_types::discover::DiscoveredFile,
995 ctx: HealthScanCtx<'_>,
996) -> Option<(String, String)> {
997 let HealthScanCtx {
998 config,
999 ignore_set,
1000 changed_files,
1001 ws_roots,
1002 } = ctx;
1003
1004 let path = &file.path;
1005 let extension = path.extension().and_then(|ext| ext.to_str());
1006 if !matches!(
1007 extension,
1008 Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte")
1009 ) {
1010 return None;
1011 }
1012 let relative = path.strip_prefix(&config.root).unwrap_or(path);
1013 if ignore_set.is_match(relative) {
1014 return None;
1015 }
1016 if let Some(changed) = changed_files
1017 && !changed.contains(path)
1018 {
1019 return None;
1020 }
1021 if let Some(roots) = ws_roots
1022 && !roots.iter().any(|root| path.starts_with(root))
1023 {
1024 return None;
1025 }
1026 let source = std::fs::read_to_string(path).ok()?;
1027 let rel = relative.to_string_lossy().replace('\\', "/");
1028 Some((rel, source))
1029}
1030
1031fn scan_markup_tailwind_arbitrary_values(
1032 files: &[fallow_types::discover::DiscoveredFile],
1033 ctx: HealthScanCtx<'_>,
1034 summary: &mut fallow_output::CssAnalyticsSummary,
1035) -> Vec<fallow_output::TailwindArbitraryValue> {
1036 let HealthScanCtx { config, .. } = ctx;
1037
1038 use fallow_output::TailwindArbitraryValue;
1039
1040 if !project_uses_tailwind(&config.root) {
1041 return Vec::new();
1042 }
1043 let mut agg: rustc_hash::FxHashMap<String, (u32, String, u32)> =
1046 rustc_hash::FxHashMap::default();
1047 let mut total_uses: u32 = 0;
1048 for file in files {
1049 let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
1050 continue;
1051 };
1052 for arb in crate::extract::scan_tailwind_arbitrary_values(&source) {
1053 total_uses = total_uses.saturating_add(1);
1054 let entry = agg
1055 .entry(arb.value)
1056 .or_insert_with(|| (0, rel.clone(), arb.line));
1057 entry.0 = entry.0.saturating_add(1);
1058 }
1059 }
1060
1061 summary.tailwind_arbitrary_values = saturate_len(agg.len());
1062 summary.tailwind_arbitrary_value_uses = total_uses;
1063 let mut out: Vec<TailwindArbitraryValue> = agg
1064 .into_iter()
1065 .map(|(value, (count, path, line))| TailwindArbitraryValue {
1066 actions: vec![fallow_output::CssCandidateAction::replace_arbitrary_value(
1067 &value,
1068 )],
1069 value,
1070 count,
1071 path,
1072 line,
1073 })
1074 .collect();
1075 out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
1076 out
1077}
1078
1079fn is_tailwind_class_byte(b: u8) -> bool {
1082 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
1083}
1084
1085fn collect_animate_keyframe_names(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
1091 let bytes = source.as_bytes();
1092 const PREFIX: &str = "animate-";
1093 let mut search = 0;
1094 while let Some(rel) = source[search..].find(PREFIX) {
1095 let start = search + rel;
1096 search = start + PREFIX.len();
1097 if start > 0 && is_tailwind_class_byte(bytes[start - 1]) {
1100 continue;
1101 }
1102 let after = start + PREFIX.len();
1103 if after >= bytes.len() {
1104 continue;
1105 }
1106 if bytes[after] == b'[' {
1107 let name_start = after + 1;
1109 let mut j = name_start;
1110 while j < bytes.len() {
1111 let c = bytes[j];
1112 if c == b'-' || c.is_ascii_alphanumeric() {
1113 j += 1;
1114 } else {
1115 break;
1116 }
1117 }
1118 if j > name_start {
1119 out.insert(source[name_start..j].to_owned());
1120 }
1121 } else {
1122 let mut j = after;
1124 while j < bytes.len() {
1125 let c = bytes[j];
1126 if c == b'-' || c.is_ascii_lowercase() || c.is_ascii_digit() {
1127 j += 1;
1128 } else {
1129 break;
1130 }
1131 }
1132 let name = source[after..j].trim_end_matches('-');
1133 if !name.is_empty() {
1134 out.insert(name.to_owned());
1135 }
1136 }
1137 }
1138}
1139
1140fn collect_markup_keyframe_references(
1149 files: &[fallow_types::discover::DiscoveredFile],
1150 config: &ResolvedConfig,
1151 ignore_set: &globset::GlobSet,
1152) -> rustc_hash::FxHashSet<String> {
1153 let mut out: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1154 for file in files {
1155 let path = &file.path;
1156 let extension = path.extension().and_then(|ext| ext.to_str());
1157 if !matches!(
1158 extension,
1159 Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte" | "js" | "ts" | "mjs" | "cjs")
1160 ) {
1161 continue;
1162 }
1163 let relative = path.strip_prefix(&config.root).unwrap_or(path);
1164 if ignore_set.is_match(relative) {
1165 continue;
1166 }
1167 if let Ok(source) = std::fs::read_to_string(path) {
1168 collect_animate_keyframe_names(&source, &mut out);
1169 collect_quoted_class_tokens(&source, &mut out, false);
1176 }
1177 }
1178 out
1179}
1180
1181const MIN_DEFINED_CLASS_LEN: usize = 6;
1187const MIN_TOKEN_LEN: usize = 5;
1190
1191fn count_stylesheet_kinds(
1196 files: &[fallow_types::discover::DiscoveredFile],
1197 config: &ResolvedConfig,
1198 ignore_set: &globset::GlobSet,
1199) -> (usize, usize) {
1200 let mut css = 0usize;
1201 let mut preprocessor = 0usize;
1202 for file in files {
1203 let path = &file.path;
1204 let kind = match path.extension().and_then(|ext| ext.to_str()) {
1205 Some("css") => &mut css,
1206 Some("scss" | "sass" | "less") => &mut preprocessor,
1207 _ => continue,
1208 };
1209 let relative = path.strip_prefix(&config.root).unwrap_or(path);
1210 if ignore_set.is_match(relative) {
1211 continue;
1212 }
1213 *kind += 1;
1214 }
1215 (css, preprocessor)
1216}
1217
1218fn collect_defined_css_classes(
1225 files: &[fallow_types::discover::DiscoveredFile],
1226 config: &ResolvedConfig,
1227 ignore_set: &globset::GlobSet,
1228) -> rustc_hash::FxHashSet<String> {
1229 use fallow_types::extract::ExportName;
1230 let mut defined: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1231 for file in files {
1232 let path = &file.path;
1233 let extension = path.extension().and_then(|ext| ext.to_str());
1234 let is_preprocessor = matches!(extension, Some("scss" | "sass" | "less"));
1235 let is_css = extension == Some("css") || is_preprocessor;
1236 let has_style_blocks = matches!(extension, Some("astro" | "vue" | "svelte"));
1237 if !is_css && !has_style_blocks {
1238 continue;
1239 }
1240 let relative = path.strip_prefix(&config.root).unwrap_or(path);
1241 if ignore_set.is_match(relative) {
1242 continue;
1243 }
1244 let Ok(source) = std::fs::read_to_string(path) else {
1245 continue;
1246 };
1247 if has_style_blocks {
1248 for style in crate::extract::extract_sfc_styles(&source) {
1249 let is_style_scss = style
1250 .lang
1251 .as_deref()
1252 .is_some_and(|lang| matches!(lang, "scss" | "sass"));
1253 for export in crate::extract::extract_css_module_exports(&style.body, is_style_scss)
1254 {
1255 if let ExportName::Named(name) = export.name {
1256 defined.insert(name);
1257 }
1258 }
1259 }
1260 continue;
1261 }
1262 for export in crate::extract::extract_css_module_exports(&source, is_preprocessor) {
1263 if let ExportName::Named(name) = export.name {
1264 defined.insert(name);
1265 }
1266 }
1267 }
1268 defined
1269}
1270
1271fn best_class_suggestion<'a>(
1276 token: &str,
1277 by_len: &'a rustc_hash::FxHashMap<usize, Vec<&'a str>>,
1278) -> Option<&'a str> {
1279 let len = token.len();
1280 let mut best: Option<&str> = None;
1281 for candidate_len in [len.wrapping_sub(1), len, len + 1] {
1282 let Some(bucket) = by_len.get(&candidate_len) else {
1283 continue;
1284 };
1285 for &defined in bucket {
1286 if defined.len() < MIN_DEFINED_CLASS_LEN {
1287 continue;
1288 }
1289 if crate::extract::is_typo_edit(token, defined)
1290 && best.is_none_or(|current| defined < current)
1291 {
1292 best = Some(defined);
1293 }
1294 }
1295 }
1296 best
1297}
1298
1299fn is_tailwind_shaped(token: &str) -> bool {
1303 token.contains([':', '/', '[', ']'])
1304}
1305
1306fn build_typo_target_index(
1311 defined: &rustc_hash::FxHashSet<String>,
1312) -> rustc_hash::FxHashMap<usize, Vec<&str>> {
1313 let mut by_len: rustc_hash::FxHashMap<usize, Vec<&str>> = rustc_hash::FxHashMap::default();
1314 for class in defined {
1315 if class.len() >= MIN_DEFINED_CLASS_LEN && !class.ends_with('-') && !class.ends_with('_') {
1316 by_len.entry(class.len()).or_default().push(class.as_str());
1317 }
1318 }
1319 by_len
1320}
1321
1322fn collect_unresolved_class_refs_in_file<'a>(
1325 source: &str,
1326 rel: &str,
1327 defined: &rustc_hash::FxHashSet<String>,
1328 by_len: &'a rustc_hash::FxHashMap<usize, Vec<&'a str>>,
1329 seen: &mut rustc_hash::FxHashSet<(String, u32, String)>,
1330 out: &mut Vec<fallow_output::UnresolvedClassReference>,
1331) {
1332 use fallow_output::{CssCandidateAction, UnresolvedClassReference};
1333 for token in crate::extract::scan_markup_class_tokens(source).static_tokens {
1334 if token.value.len() < MIN_TOKEN_LEN
1335 || is_tailwind_shaped(&token.value)
1336 || defined.contains(&token.value)
1337 {
1338 continue;
1339 }
1340 let Some(suggestion) = best_class_suggestion(&token.value, by_len) else {
1341 continue;
1342 };
1343 let key = (rel.to_owned(), token.line, token.value.clone());
1344 if !seen.insert(key) {
1345 continue;
1346 }
1347 out.push(UnresolvedClassReference {
1348 actions: vec![CssCandidateAction::verify_unresolved_class(
1349 &token.value,
1350 suggestion,
1351 )],
1352 class: token.value,
1353 suggestion: suggestion.to_owned(),
1354 path: rel.to_owned(),
1355 line: token.line,
1356 });
1357 }
1358}
1359
1360fn scan_unresolved_class_references(
1367 files: &[fallow_types::discover::DiscoveredFile],
1368 ctx: HealthScanCtx<'_>,
1369 summary: &mut fallow_output::CssAnalyticsSummary,
1370) -> Vec<fallow_output::UnresolvedClassReference> {
1371 let HealthScanCtx {
1372 config, ignore_set, ..
1373 } = ctx;
1374
1375 use fallow_output::UnresolvedClassReference;
1376
1377 let (css_files, preprocessor_files) = count_stylesheet_kinds(files, config, ignore_set);
1385 if preprocessor_files > css_files {
1386 return Vec::new();
1387 }
1388
1389 let defined = collect_defined_css_classes(files, config, ignore_set);
1390 if defined.is_empty() {
1391 return Vec::new();
1392 }
1393 let by_len = build_typo_target_index(&defined);
1394
1395 let mut out: Vec<UnresolvedClassReference> = Vec::new();
1396 let mut seen: rustc_hash::FxHashSet<(String, u32, String)> = rustc_hash::FxHashSet::default();
1397 for file in files {
1398 let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
1399 continue;
1400 };
1401 collect_unresolved_class_refs_in_file(
1402 &source, &rel, &defined, &by_len, &mut seen, &mut out,
1403 );
1404 }
1405
1406 out.sort_by(|a, b| {
1407 a.path
1408 .cmp(&b.path)
1409 .then_with(|| a.line.cmp(&b.line))
1410 .then_with(|| a.class.cmp(&b.class))
1411 });
1412 summary.unresolved_class_references = saturate_len(out.len());
1413 out
1414}
1415
1416fn mask_font_face_blocks(lower_source: &str) -> String {
1423 if !lower_source.contains("@font-face") {
1424 return lower_source.to_owned();
1425 }
1426 let mut bytes = lower_source.as_bytes().to_vec();
1427 let sb = lower_source.as_bytes();
1428 let mut search = 0;
1429 while let Some(rel) = lower_source[search..].find("@font-face") {
1430 let start = search + rel;
1431 let Some(brace_rel) = lower_source[start..].find('{') else {
1432 break;
1433 };
1434 let mut depth = 0usize;
1435 let mut j = start + brace_rel;
1436 while j < sb.len() {
1437 match sb[j] {
1438 b'{' => depth += 1,
1439 b'}' => {
1440 depth -= 1;
1441 if depth == 0 {
1442 break;
1443 }
1444 }
1445 _ => {}
1446 }
1447 j += 1;
1448 }
1449 let end = (j + 1).min(bytes.len());
1450 for b in &mut bytes[start..end] {
1451 *b = b' ';
1452 }
1453 search = end;
1454 }
1455 String::from_utf8(bytes).unwrap_or_else(|_| lower_source.to_owned())
1456}
1457
1458fn font_families_referenced_in_source(
1466 candidates: &[fallow_output::UnusedFontFace],
1467 files: &[fallow_types::discover::DiscoveredFile],
1468 config: &ResolvedConfig,
1469 ignore_set: &globset::GlobSet,
1470) -> rustc_hash::FxHashSet<String> {
1471 let mut pending: Vec<(String, String)> = candidates
1475 .iter()
1476 .map(|c| (c.family.clone(), c.family.to_ascii_lowercase()))
1477 .collect();
1478 let mut found: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1479 for file in files {
1480 if pending.is_empty() {
1481 break;
1482 }
1483 let path = &file.path;
1484 let extension = path.extension().and_then(|ext| ext.to_str());
1485 if !matches!(
1486 extension,
1487 Some(
1488 "css"
1489 | "scss"
1490 | "sass"
1491 | "less"
1492 | "js"
1493 | "jsx"
1494 | "ts"
1495 | "tsx"
1496 | "mjs"
1497 | "cjs"
1498 | "vue"
1499 | "svelte"
1500 | "astro"
1501 | "html"
1502 | "mdx"
1503 )
1504 ) {
1505 continue;
1506 }
1507 let relative = path.strip_prefix(&config.root).unwrap_or(path);
1508 if ignore_set.is_match(relative) {
1509 continue;
1510 }
1511 let Ok(source) = std::fs::read_to_string(path) else {
1512 continue;
1513 };
1514 let source_lower = mask_font_face_blocks(&source.to_ascii_lowercase());
1520 pending.retain(|(family, family_lower)| {
1521 if source_lower.contains(family_lower.as_str()) {
1522 found.insert(family.clone());
1523 false
1524 } else {
1525 true
1526 }
1527 });
1528 }
1529 found
1530}
1531
1532const MIN_UNREF_CLASS_LEN: usize = 5;
1536
1537const MIN_DEP_PREFIX_LEN: usize = 6;
1541
1542fn normalize_dep_token(name: &str) -> String {
1546 name.chars()
1547 .filter(char::is_ascii_alphanumeric)
1548 .map(|c| c.to_ascii_lowercase())
1549 .collect()
1550}
1551
1552fn dependency_class_prefixes(config: &ResolvedConfig) -> rustc_hash::FxHashSet<String> {
1557 let mut prefixes: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1558 let Ok(text) = std::fs::read_to_string(config.root.join("package.json")) else {
1559 return prefixes;
1560 };
1561 let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
1562 return prefixes;
1563 };
1564 for key in ["dependencies", "devDependencies", "peerDependencies"] {
1565 if let Some(deps) = json.get(key).and_then(serde_json::Value::as_object) {
1566 for name in deps.keys() {
1567 let bare = name.rsplit('/').next().unwrap_or(name);
1570 let normalized = normalize_dep_token(bare);
1571 if normalized.len() >= MIN_DEP_PREFIX_LEN {
1572 prefixes.insert(normalized);
1573 }
1574 }
1575 }
1576 }
1577 prefixes
1578}
1579
1580fn class_matches_dependency_prefix(
1586 class: &str,
1587 dependency_prefixes: &rustc_hash::FxHashSet<String>,
1588) -> bool {
1589 if dependency_prefixes.is_empty() {
1590 return false;
1591 }
1592 let normalized = normalize_dep_token(class);
1593 dependency_prefixes
1594 .iter()
1595 .any(|prefix| normalized.starts_with(prefix.as_str()))
1596}
1597
1598fn collect_quoted_class_tokens(
1612 source: &str,
1613 out: &mut rustc_hash::FxHashSet<String>,
1614 require_dash: bool,
1615) {
1616 let bytes = source.as_bytes();
1617 let mut i = 0;
1618 while i < bytes.len() {
1619 let quote = bytes[i];
1620 if quote == b'"' || quote == b'\'' || quote == b'`' {
1621 let start = i + 1;
1622 let mut j = start;
1623 while j < bytes.len() && bytes[j] != quote {
1624 j += 1;
1625 }
1626 if let Some(content) = source.get(start..j) {
1627 for token in content
1628 .split(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
1629 {
1630 let shaped = token.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
1631 && !token.ends_with('-')
1632 && (if require_dash {
1633 token.contains('-')
1634 } else {
1635 token.len() >= 3
1636 });
1637 if shaped {
1638 out.insert(token.to_owned());
1639 }
1640 }
1641 }
1642 i = j + 1;
1643 } else {
1644 i += 1;
1645 }
1646 }
1647}
1648
1649fn collect_global_scoped_classes(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
1658 let bytes = source.as_bytes();
1659 let mut i = 0;
1660 while let Some(rel) = source[i..].find(":global(") {
1661 let open = i + rel + ":global(".len();
1662 let mut depth = 1usize;
1664 let mut j = open;
1665 while j < bytes.len() && depth > 0 {
1666 match bytes[j] {
1667 b'(' => depth += 1,
1668 b')' => depth -= 1,
1669 _ => {}
1670 }
1671 j += 1;
1672 }
1673 let inner_end = j.saturating_sub(1).max(open);
1674 if let Some(inner) = source.get(open..inner_end) {
1675 extract_dotted_class_names(inner, out);
1676 }
1677 i = j.max(open + 1);
1678 }
1679}
1680
1681fn extract_dotted_class_names(selector: &str, out: &mut rustc_hash::FxHashSet<String>) {
1685 let bytes = selector.as_bytes();
1686 let mut i = 0;
1687 while i < bytes.len() {
1688 if bytes[i] == b'.' {
1689 let start = i + 1;
1690 if start < bytes.len()
1691 && (bytes[start].is_ascii_alphabetic() || matches!(bytes[start], b'_' | b'-'))
1692 {
1693 let mut j = start;
1694 while j < bytes.len()
1695 && (bytes[j].is_ascii_alphanumeric() || matches!(bytes[j], b'_' | b'-'))
1696 {
1697 j += 1;
1698 }
1699 if let Some(name) = selector.get(start..j) {
1700 out.insert(name.to_owned());
1701 }
1702 i = j;
1703 continue;
1704 }
1705 }
1706 i += 1;
1707 }
1708}
1709
1710fn collect_defined_css_classes_located(
1717 files: &[fallow_types::discover::DiscoveredFile],
1718 config: &ResolvedConfig,
1719 ignore_set: &globset::GlobSet,
1720) -> Vec<(String, Vec<(String, u32)>)> {
1721 use fallow_types::extract::ExportName;
1722 let mut out: Vec<(String, Vec<(String, u32)>)> = Vec::new();
1723 for file in files {
1724 let path = &file.path;
1725 let extension = path.extension().and_then(|ext| ext.to_str());
1726 let is_scss = extension == Some("scss");
1727 if extension != Some("css") && !is_scss {
1728 continue;
1729 }
1730 let relative = path.strip_prefix(&config.root).unwrap_or(path);
1731 if ignore_set.is_match(relative) {
1732 continue;
1733 }
1734 let Ok(source) = std::fs::read_to_string(path) else {
1735 continue;
1736 };
1737 let mut global_scoped: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1738 collect_global_scoped_classes(&source, &mut global_scoped);
1739 let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1740 let mut classes: Vec<(String, u32)> = Vec::new();
1741 for export in crate::extract::extract_css_module_exports(&source, is_scss) {
1742 let ExportName::Named(name) = export.name else {
1743 continue;
1744 };
1745 if global_scoped.contains(&name) {
1749 continue;
1750 }
1751 if !seen.insert(name.clone()) {
1752 continue;
1753 }
1754 let start = export.span.start as usize;
1755 let line = 1 + source
1756 .get(..start)
1757 .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
1758 classes.push((name, u32::try_from(line).unwrap_or(u32::MAX)));
1759 }
1760 if !classes.is_empty() {
1761 out.push((relative.to_string_lossy().replace('\\', "/"), classes));
1762 }
1763 }
1764 out
1765}
1766
1767fn published_css_paths(config: &ResolvedConfig) -> rustc_hash::FxHashSet<String> {
1773 let mut published: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1774 let Ok(text) = std::fs::read_to_string(config.root.join("package.json")) else {
1775 return published;
1776 };
1777 let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
1778 return published;
1779 };
1780 let normalize = |s: &str| s.trim_start_matches("./").replace('\\', "/");
1781 let is_css = |s: &str| {
1782 matches!(
1783 std::path::Path::new(s)
1784 .extension()
1785 .and_then(|e| e.to_str())
1786 .map(str::to_ascii_lowercase)
1787 .as_deref(),
1788 Some("css" | "scss")
1789 )
1790 };
1791 for key in ["style", "main", "sass", "module"] {
1792 if let Some(s) = json.get(key).and_then(serde_json::Value::as_str)
1793 && is_css(s)
1794 {
1795 published.insert(normalize(s));
1796 }
1797 }
1798 let mut stack = vec![
1800 json.get("exports")
1801 .cloned()
1802 .unwrap_or(serde_json::Value::Null),
1803 ];
1804 while let Some(node) = stack.pop() {
1805 match node {
1806 serde_json::Value::String(s) if is_css(&s) => {
1807 published.insert(normalize(&s));
1808 }
1809 serde_json::Value::Array(items) => stack.extend(items),
1810 serde_json::Value::Object(map) => stack.extend(map.into_values()),
1811 _ => {}
1812 }
1813 }
1814 published
1815}
1816
1817fn scan_unreferenced_css_classes(
1832 files: &[fallow_types::discover::DiscoveredFile],
1833 ctx: HealthScanCtx<'_>,
1834 summary: &mut fallow_output::CssAnalyticsSummary,
1835) -> Vec<fallow_output::UnreferencedCssClass> {
1836 let HealthScanCtx {
1837 config,
1838 ignore_set,
1839 changed_files,
1840 ws_roots,
1841 } = ctx;
1842
1843 use fallow_output::UnreferencedCssClass;
1844
1845 if changed_files.is_some() || ws_roots.is_some() {
1847 return Vec::new();
1848 }
1849 let (css_files, preprocessor_files) = count_stylesheet_kinds(files, config, ignore_set);
1851 if preprocessor_files > css_files {
1852 return Vec::new();
1853 }
1854
1855 let reference_surface = css_reference_surface(files, config, ignore_set);
1856
1857 let published = published_css_paths(config);
1858 let dependency_prefixes = dependency_class_prefixes(config);
1859 let located = collect_defined_css_classes_located(files, config, ignore_set);
1860
1861 let mut out: Vec<UnreferencedCssClass> = Vec::new();
1862 for (rel, classes) in located {
1863 push_unreferenced_css_class_candidates(
1864 &mut out,
1865 &rel,
1866 classes,
1867 &published,
1868 &dependency_prefixes,
1869 &reference_surface,
1870 );
1871 }
1872
1873 out.sort_by(|a, b| {
1874 a.path
1875 .cmp(&b.path)
1876 .then_with(|| a.line.cmp(&b.line))
1877 .then_with(|| a.class.cmp(&b.class))
1878 });
1879 summary.unreferenced_css_classes = saturate_len(out.len());
1880 out
1881}
1882
1883struct CssReferenceSurface {
1884 static_tokens: rustc_hash::FxHashSet<String>,
1885 dynamic_corpus: String,
1886}
1887
1888impl CssReferenceSurface {
1889 fn references(&self, class: &str) -> bool {
1890 self.static_tokens.contains(class)
1891 || self.dynamic_corpus.contains(class)
1892 || self.dynamic_prefix_referenced(class)
1893 }
1894
1895 fn dynamic_prefix_referenced(&self, class: &str) -> bool {
1896 let Some(dash) = class.rfind('-') else {
1897 return false;
1898 };
1899 let head = &class[..=dash];
1900 const INTERP_MARKERS: [&str; 6] = ["${", "' +", "'+", "\" +", "\"+", "` +"];
1901 INTERP_MARKERS
1902 .iter()
1903 .any(|marker| self.dynamic_corpus.contains(&format!("{head}{marker}")))
1904 }
1905}
1906
1907fn css_reference_surface(
1908 files: &[fallow_types::discover::DiscoveredFile],
1909 config: &ResolvedConfig,
1910 ignore_set: &globset::GlobSet,
1911) -> CssReferenceSurface {
1912 let mut surface = CssReferenceSurface {
1913 static_tokens: rustc_hash::FxHashSet::default(),
1914 dynamic_corpus: String::new(),
1915 };
1916 for file in files {
1917 collect_css_reference_surface_file(&mut surface, file, config, ignore_set);
1918 }
1919 surface
1920}
1921
1922fn collect_css_reference_surface_file(
1923 surface: &mut CssReferenceSurface,
1924 file: &fallow_types::discover::DiscoveredFile,
1925 config: &ResolvedConfig,
1926 ignore_set: &globset::GlobSet,
1927) {
1928 let path = &file.path;
1929 let extension = path.extension().and_then(|ext| ext.to_str());
1930 if !matches!(
1931 extension,
1932 Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte")
1933 ) {
1934 return;
1935 }
1936 let relative = path.strip_prefix(&config.root).unwrap_or(path);
1937 if ignore_set.is_match(relative) {
1938 return;
1939 }
1940 let Ok(source) = std::fs::read_to_string(path) else {
1941 return;
1942 };
1943 let scan = crate::extract::scan_markup_class_tokens(&source);
1944 for token in scan.static_tokens {
1945 surface.static_tokens.insert(token.value);
1946 }
1947 collect_quoted_class_tokens(&source, &mut surface.static_tokens, true);
1948 if scan.has_dynamic {
1949 surface.dynamic_corpus.push_str(&source);
1950 surface.dynamic_corpus.push('\n');
1951 }
1952}
1953
1954fn push_unreferenced_css_class_candidates(
1955 out: &mut Vec<fallow_output::UnreferencedCssClass>,
1956 rel: &str,
1957 classes: Vec<(String, u32)>,
1958 published: &rustc_hash::FxHashSet<String>,
1959 dependency_prefixes: &rustc_hash::FxHashSet<String>,
1960 reference_surface: &CssReferenceSurface,
1961) {
1962 use fallow_output::{CssCandidateAction, UnreferencedCssClass};
1963
1964 if published.contains(rel)
1965 || !classes
1966 .iter()
1967 .any(|(class, _)| reference_surface.references(class))
1968 {
1969 return;
1970 }
1971 for (class, line) in classes {
1972 if class.len() >= MIN_UNREF_CLASS_LEN
1973 && !reference_surface.references(&class)
1974 && !class_matches_dependency_prefix(&class, dependency_prefixes)
1975 {
1976 out.push(UnreferencedCssClass {
1977 actions: vec![CssCandidateAction::verify_unreferenced_class(&class)],
1978 class,
1979 path: rel.to_string(),
1980 line,
1981 });
1982 }
1983 }
1984}
1985
1986const THEME_USAGE_SOURCE_EXTS: &[&str] = &[
1992 "scss", "sass", "less", "js", "jsx", "ts", "tsx", "mjs", "cjs", "vue", "svelte", "astro",
1993 "html", "mdx",
1994];
1995
1996fn collect_class_shaped_tokens(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
2005 let bytes = source.as_bytes();
2006 let mut i = 0;
2007 while i < bytes.len() {
2008 let b = bytes[i];
2009 if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
2010 let start = i;
2011 while i < bytes.len() {
2012 let c = bytes[i];
2013 if c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' {
2014 i += 1;
2015 } else {
2016 break;
2017 }
2018 }
2019 let tok = source[start..i].trim_matches('-');
2020 if tok.contains('-') && tok.as_bytes().first().is_some_and(u8::is_ascii_lowercase) {
2021 out.insert(tok.to_owned());
2022 }
2023 } else {
2024 i += 1;
2025 }
2026 }
2027}
2028
2029fn text_has_nonempty_plugins_array(text: &str) -> bool {
2034 let bytes = text.as_bytes();
2035 let skip_ws = |mut k: usize| {
2036 while k < bytes.len() && bytes[k].is_ascii_whitespace() {
2037 k += 1;
2038 }
2039 k
2040 };
2041 let mut from = 0;
2042 while let Some(rel) = text[from..].find("plugins") {
2043 let mut k = skip_ws(from + rel + "plugins".len());
2044 if k < bytes.len() && bytes[k] == b':' {
2045 k = skip_ws(k + 1);
2046 if k < bytes.len() && bytes[k] == b'[' {
2047 k = skip_ws(k + 1);
2048 if k < bytes.len() && bytes[k] != b']' {
2049 return true;
2050 }
2051 }
2052 }
2053 from = from + rel + "plugins".len();
2054 }
2055 false
2056}
2057
2058fn project_uses_tailwind_plugin(any_plugin_directive: bool, root: &std::path::Path) -> bool {
2064 if any_plugin_directive {
2065 return true;
2066 }
2067 for name in [
2068 "tailwind.config.js",
2069 "tailwind.config.ts",
2070 "tailwind.config.mjs",
2071 "tailwind.config.cjs",
2072 "tailwind.config.mts",
2073 "tailwind.config.cts",
2074 ] {
2075 if let Ok(text) = std::fs::read_to_string(root.join(name))
2076 && text_has_nonempty_plugins_array(&text)
2077 {
2078 return true;
2079 }
2080 }
2081 false
2082}
2083
2084struct UnusedThemeTokenScanInput<'a> {
2106 tokens: &'a CssTokenSets,
2107 files: &'a [fallow_types::discover::DiscoveredFile],
2108 config: &'a ResolvedConfig,
2109 ignore_set: &'a globset::GlobSet,
2110 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2111 ws_roots: Option<&'a [std::path::PathBuf]>,
2112 summary: &'a mut fallow_output::CssAnalyticsSummary,
2113}
2114
2115struct ThemeTokenCandidate {
2118 token: String,
2119 namespace: String,
2120 name: String,
2121 path: String,
2122 line: u32,
2123}
2124
2125fn classify_theme_token_candidates(
2128 input: &UnusedThemeTokenScanInput<'_>,
2129) -> Vec<ThemeTokenCandidate> {
2130 let published = published_css_paths(input.config);
2131 let mut candidates: Vec<ThemeTokenCandidate> = Vec::new();
2132 for (raw, (path, line)) in &input.tokens.theme_token_definers {
2133 if published.contains(path) {
2134 continue;
2135 }
2136 let Some(classified) = tailwind_theme::classify(raw) else {
2137 continue;
2138 };
2139 if classified.is_variant {
2140 continue;
2141 }
2142 candidates.push(ThemeTokenCandidate {
2143 token: format!("--{raw}"),
2144 namespace: classified.namespace,
2145 name: classified.name,
2146 path: path.clone(),
2147 line: *line,
2148 });
2149 }
2150 candidates
2151}
2152
2153fn collect_theme_usage_tokens(
2156 input: &UnusedThemeTokenScanInput<'_>,
2157) -> rustc_hash::FxHashSet<String> {
2158 let mut utility_tokens: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
2159 for apply in &input.tokens.apply_tokens {
2160 collect_class_shaped_tokens(apply, &mut utility_tokens);
2161 }
2162 for file in input.files {
2163 let path = &file.path;
2164 let extension = path.extension().and_then(|ext| ext.to_str());
2165 if !extension.is_some_and(|ext| THEME_USAGE_SOURCE_EXTS.contains(&ext)) {
2166 continue;
2167 }
2168 let relative = path.strip_prefix(&input.config.root).unwrap_or(path);
2169 if input.ignore_set.is_match(relative) {
2170 continue;
2171 }
2172 if let Ok(source) = std::fs::read_to_string(path) {
2173 collect_class_shaped_tokens(&source, &mut utility_tokens);
2174 }
2175 }
2176 utility_tokens
2177}
2178
2179fn collect_theme_var_reads(tokens: &CssTokenSets) -> rustc_hash::FxHashSet<String> {
2182 let mut var_reads: rustc_hash::FxHashSet<String> = tokens.theme_var_reads.clone();
2183 for referenced in &tokens.referenced_custom_props {
2184 var_reads.insert(referenced.trim_start_matches('-').to_owned());
2185 }
2186 var_reads
2187}
2188
2189fn scan_unused_theme_tokens(
2190 input: &mut UnusedThemeTokenScanInput<'_>,
2191) -> Vec<fallow_output::UnusedThemeToken> {
2192 use fallow_output::{CssCandidateAction, UnusedThemeToken};
2193
2194 if input.changed_files.is_some() || input.ws_roots.is_some() {
2196 return Vec::new();
2197 }
2198 if input.tokens.theme_token_definers.is_empty() || !project_uses_tailwind(&input.config.root) {
2200 return Vec::new();
2201 }
2202 if project_uses_tailwind_plugin(input.tokens.any_plugin_directive, &input.config.root) {
2204 return Vec::new();
2205 }
2206
2207 let candidates = classify_theme_token_candidates(input);
2208 if candidates.is_empty() {
2209 input.summary.unused_theme_tokens = 0;
2210 return Vec::new();
2211 }
2212
2213 let utility_tokens = collect_theme_usage_tokens(input);
2214 let var_reads = collect_theme_var_reads(input.tokens);
2215
2216 let mut out: Vec<UnusedThemeToken> = Vec::new();
2217 for candidate in candidates {
2218 let dash_name = format!("-{}", candidate.name);
2219 let raw = candidate.token.trim_start_matches('-');
2221 let used = var_reads.contains(raw)
2222 || utility_tokens
2223 .iter()
2224 .any(|t| t.len() > dash_name.len() && t.ends_with(&dash_name));
2225 if used {
2226 continue;
2227 }
2228 out.push(UnusedThemeToken {
2229 actions: vec![CssCandidateAction::verify_unused_theme_token(
2230 &candidate.token,
2231 &candidate.namespace,
2232 &candidate.name,
2233 )],
2234 token: candidate.token,
2235 namespace: candidate.namespace,
2236 path: candidate.path,
2237 line: candidate.line,
2238 });
2239 }
2240 out.sort_by(|a, b| {
2241 a.path
2242 .cmp(&b.path)
2243 .then_with(|| a.line.cmp(&b.line))
2244 .then_with(|| a.token.cmp(&b.token))
2245 });
2246 input.summary.unused_theme_tokens = saturate_len(out.len());
2247 out
2248}
2249
2250struct MarkupCssCandidates {
2253 tailwind_arbitrary_values: Vec<fallow_output::TailwindArbitraryValue>,
2254 unresolved_class_references: Vec<fallow_output::UnresolvedClassReference>,
2255 unreferenced_css_classes: Vec<fallow_output::UnreferencedCssClass>,
2256 unused_theme_tokens: Vec<fallow_output::UnusedThemeToken>,
2257}
2258
2259struct MarkupCssCandidateInput<'a> {
2264 tokens: &'a CssTokenSets,
2265 files: &'a [fallow_types::discover::DiscoveredFile],
2266 config: &'a ResolvedConfig,
2267 ignore_set: &'a globset::GlobSet,
2268 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2269 ws_roots: Option<&'a [std::path::PathBuf]>,
2270 summary: &'a mut fallow_output::CssAnalyticsSummary,
2271}
2272
2273fn scan_markup_css_candidates(input: &mut MarkupCssCandidateInput<'_>) -> MarkupCssCandidates {
2274 MarkupCssCandidates {
2275 tailwind_arbitrary_values: scan_markup_tailwind_arbitrary_values(
2277 input.files,
2278 HealthScanCtx {
2279 config: input.config,
2280 ignore_set: input.ignore_set,
2281 changed_files: input.changed_files,
2282 ws_roots: input.ws_roots,
2283 },
2284 input.summary,
2285 ),
2286 unresolved_class_references: scan_unresolved_class_references(
2288 input.files,
2289 HealthScanCtx {
2290 config: input.config,
2291 ignore_set: input.ignore_set,
2292 changed_files: input.changed_files,
2293 ws_roots: input.ws_roots,
2294 },
2295 input.summary,
2296 ),
2297 unreferenced_css_classes: scan_unreferenced_css_classes(
2299 input.files,
2300 HealthScanCtx {
2301 config: input.config,
2302 ignore_set: input.ignore_set,
2303 changed_files: input.changed_files,
2304 ws_roots: input.ws_roots,
2305 },
2306 input.summary,
2307 ),
2308 unused_theme_tokens: scan_unused_theme_tokens(&mut UnusedThemeTokenScanInput {
2311 tokens: input.tokens,
2312 files: input.files,
2313 config: input.config,
2314 ignore_set: input.ignore_set,
2315 changed_files: input.changed_files,
2316 ws_roots: input.ws_roots,
2317 summary: input.summary,
2318 }),
2319 }
2320}
2321
2322fn css_report_scan_target<'a>(
2323 file: &'a fallow_types::discover::DiscoveredFile,
2324 ctx: HealthScanCtx<'_>,
2325) -> Option<(&'a std::path::Path, bool)> {
2326 let HealthScanCtx {
2327 config,
2328 ignore_set,
2329 changed_files,
2330 ws_roots,
2331 } = ctx;
2332
2333 let path = &file.path;
2334 let extension = path.extension().and_then(|ext| ext.to_str());
2335 let is_css = extension == Some("css");
2336 let is_sfc = matches!(extension, Some("vue") | Some("svelte"));
2337 if !is_css && !is_sfc {
2338 return None;
2339 }
2340
2341 let relative = path.strip_prefix(&config.root).unwrap_or(path);
2342 if ignore_set.is_match(relative) {
2343 return None;
2344 }
2345 if let Some(changed) = changed_files
2346 && !changed.contains(path)
2347 {
2348 return None;
2349 }
2350 if let Some(roots) = ws_roots
2351 && !roots.iter().any(|root| path.starts_with(root))
2352 {
2353 return None;
2354 }
2355 Some((relative, is_sfc))
2356}
2357
2358fn record_scoped_unused_classes(
2359 source: &str,
2360 relative: &std::path::Path,
2361 summary: &mut fallow_output::CssAnalyticsSummary,
2362 scoped_unused: &mut Vec<fallow_output::ScopedUnusedClasses>,
2363) {
2364 let classes = crate::extract::scoped_unused_classes(source);
2365 if classes.is_empty() {
2366 return;
2367 }
2368
2369 summary.scoped_unused_classes = summary
2370 .scoped_unused_classes
2371 .saturating_add(u32::try_from(classes.len()).unwrap_or(u32::MAX));
2372 scoped_unused.push(fallow_output::ScopedUnusedClasses {
2373 path: relative.to_string_lossy().replace('\\', "/"),
2374 classes,
2375 actions: vec![fallow_output::CssCandidateAction::verify_scoped_classes()],
2376 });
2377}
2378
2379fn css_report_stylesheet_source(source: &str, is_sfc: bool) -> Option<std::borrow::Cow<'_, str>> {
2380 if is_sfc {
2381 return crate::extract::sfc_virtual_stylesheet(source).map(std::borrow::Cow::Owned);
2382 }
2383
2384 Some(std::borrow::Cow::Borrowed(source))
2385}
2386
2387fn record_css_analytics_summary(
2388 summary: &mut fallow_output::CssAnalyticsSummary,
2389 analytics: &fallow_types::extract::CssAnalytics,
2390) {
2391 summary.files_analyzed = summary.files_analyzed.saturating_add(1);
2392 summary.total_rules = summary.total_rules.saturating_add(analytics.rule_count);
2393 summary.total_declarations = summary
2394 .total_declarations
2395 .saturating_add(analytics.total_declarations);
2396 summary.important_declarations = summary
2397 .important_declarations
2398 .saturating_add(analytics.important_declarations);
2399 summary.empty_rules = summary
2400 .empty_rules
2401 .saturating_add(analytics.empty_rule_count);
2402 summary.max_nesting_depth = summary.max_nesting_depth.max(analytics.max_nesting_depth);
2403 if analytics.notable_truncated {
2404 summary.notable_truncated_files = summary.notable_truncated_files.saturating_add(1);
2405 }
2406}
2407
2408struct CssWalkAccum {
2411 file_reports: Vec<fallow_output::CssFileAnalytics>,
2412 summary: fallow_output::CssAnalyticsSummary,
2413 scoped_unused: Vec<fallow_output::ScopedUnusedClasses>,
2414 tokens: CssTokenSets,
2415}
2416
2417struct CssTokenMetrics {
2420 unreferenced_keyframes: Vec<fallow_output::UnreferencedKeyframes>,
2421 undefined_keyframes: Vec<fallow_output::UndefinedKeyframes>,
2422 duplicate_declaration_blocks: Vec<fallow_output::CssDuplicateBlock>,
2423 unused_at_rules: Vec<fallow_output::UnusedAtRule>,
2424 font_size_unit_mix: Option<fallow_output::CssNotationConsistency>,
2425 unused_font_faces: Vec<fallow_output::UnusedFontFace>,
2426}
2427
2428fn walk_css_files(
2431 files: &[fallow_types::discover::DiscoveredFile],
2432 ctx: HealthScanCtx<'_>,
2433) -> CssWalkAccum {
2434 use fallow_output::{CssAnalyticsSummary, CssFileAnalytics, ScopedUnusedClasses};
2435
2436 let mut file_reports = Vec::new();
2437 let mut summary = CssAnalyticsSummary::default();
2438 let mut scoped_unused: Vec<ScopedUnusedClasses> = Vec::new();
2439 let mut tokens = CssTokenSets::default();
2443
2444 for file in files {
2445 let Some((relative, is_sfc)) = css_report_scan_target(file, ctx) else {
2446 continue;
2447 };
2448 let Ok(source) = std::fs::read_to_string(&file.path) else {
2449 continue;
2450 };
2451
2452 if is_sfc {
2453 record_scoped_unused_classes(&source, relative, &mut summary, &mut scoped_unused);
2454 }
2455
2456 let Some(css_source) = css_report_stylesheet_source(&source, is_sfc) else {
2460 continue;
2461 };
2462 let Some(analytics) = crate::extract::compute_css_analytics(&css_source) else {
2463 continue;
2464 };
2465
2466 let rel = relative.to_string_lossy().replace('\\', "/");
2467 record_css_analytics_summary(&mut summary, &analytics);
2468 tokens.record(&analytics, &rel);
2469 tokens.record_theme(css_source.as_ref(), &rel);
2470
2471 if !analytics.notable_rules.is_empty() {
2472 file_reports.push(CssFileAnalytics {
2473 path: rel,
2474 analytics,
2475 });
2476 }
2477 }
2478
2479 CssWalkAccum {
2480 file_reports,
2481 summary,
2482 scoped_unused,
2483 tokens,
2484 }
2485}
2486
2487fn finalize_css_token_metrics(
2490 tokens: &mut CssTokenSets,
2491 summary: &mut fallow_output::CssAnalyticsSummary,
2492 files: &[fallow_types::discover::DiscoveredFile],
2493 config: &ResolvedConfig,
2494 ignore_set: &globset::GlobSet,
2495) -> CssTokenMetrics {
2496 for name in collect_markup_keyframe_references(files, config, ignore_set) {
2501 if tokens.defined_keyframes.contains(&name) {
2502 tokens.referenced_keyframes.insert(name);
2503 }
2504 }
2505
2506 let (unreferenced_keyframes, undefined_keyframes) = tokens.finalize(summary);
2507 let duplicate_declaration_blocks = tokens.group_duplicate_blocks(summary);
2508 let unused_at_rules = tokens.group_unused_at_rules(summary);
2509 let font_size_unit_mix = tokens.font_size_unit_mix(summary);
2510 let mut unused_font_faces = tokens.unused_font_faces(summary);
2511 if !unused_font_faces.is_empty() {
2517 let referenced =
2518 font_families_referenced_in_source(&unused_font_faces, files, config, ignore_set);
2519 unused_font_faces.retain(|ff| !referenced.contains(&ff.family));
2520 summary.unused_font_faces = saturate_len(unused_font_faces.len());
2521 }
2522
2523 CssTokenMetrics {
2524 unreferenced_keyframes,
2525 undefined_keyframes,
2526 duplicate_declaration_blocks,
2527 unused_at_rules,
2528 font_size_unit_mix,
2529 unused_font_faces,
2530 }
2531}
2532
2533fn compute_css_analytics_report(
2534 files: &[fallow_types::discover::DiscoveredFile],
2535 ctx: HealthScanCtx<'_>,
2536) -> Option<fallow_output::CssAnalyticsReport> {
2537 let HealthScanCtx {
2538 config,
2539 ignore_set,
2540 changed_files,
2541 ws_roots,
2542 } = ctx;
2543
2544 let mut walk = walk_css_files(files, ctx);
2545 let metrics = finalize_css_token_metrics(
2546 &mut walk.tokens,
2547 &mut walk.summary,
2548 files,
2549 config,
2550 ignore_set,
2551 );
2552 let candidates = scan_markup_css_candidates(&mut MarkupCssCandidateInput {
2553 tokens: &walk.tokens,
2554 files,
2555 config,
2556 ignore_set,
2557 changed_files,
2558 ws_roots,
2559 summary: &mut walk.summary,
2560 });
2561 assemble_css_report(walk, metrics, candidates)
2562}
2563
2564fn assemble_css_report(
2568 walk: CssWalkAccum,
2569 metrics: CssTokenMetrics,
2570 candidates: MarkupCssCandidates,
2571) -> Option<fallow_output::CssAnalyticsReport> {
2572 use fallow_output::CssAnalyticsReport;
2573
2574 let candidates_empty = candidates.tailwind_arbitrary_values.is_empty()
2575 && candidates.unresolved_class_references.is_empty()
2576 && candidates.unreferenced_css_classes.is_empty()
2577 && metrics.unused_font_faces.is_empty()
2578 && candidates.unused_theme_tokens.is_empty();
2579 if walk.summary.files_analyzed == 0 && walk.scoped_unused.is_empty() && candidates_empty {
2580 return None;
2581 }
2582 let mut scoped_unused = walk.scoped_unused;
2583 scoped_unused.sort_by(|a, b| a.path.cmp(&b.path));
2584 Some(CssAnalyticsReport {
2585 files: walk.file_reports,
2586 summary: walk.summary,
2587 scoped_unused,
2588 unreferenced_keyframes: metrics.unreferenced_keyframes,
2589 undefined_keyframes: metrics.undefined_keyframes,
2590 duplicate_declaration_blocks: metrics.duplicate_declaration_blocks,
2591 tailwind_arbitrary_values: candidates.tailwind_arbitrary_values,
2592 unused_at_rules: metrics.unused_at_rules,
2593 unresolved_class_references: candidates.unresolved_class_references,
2594 unreferenced_css_classes: candidates.unreferenced_css_classes,
2595 unused_font_faces: metrics.unused_font_faces,
2596 unused_theme_tokens: candidates.unused_theme_tokens,
2597 font_size_unit_mix: metrics.font_size_unit_mix,
2598 })
2599}
2600
2601struct HealthCoverageSettings {
2602 report_coverage_gaps: bool,
2603 enforce_coverage_gaps: bool,
2604 istanbul_coverage: Option<scoring::IstanbulCoverage>,
2605}
2606
2607struct HealthFindingsData {
2608 findings: Vec<ComplexityViolation>,
2609 threshold_overrides: Vec<fallow_output::ThresholdOverrideState>,
2610 files_analyzed: usize,
2611 total_functions: usize,
2612 complexity_ms: f64,
2613 total_above_threshold: usize,
2614 sev_critical: usize,
2615 sev_high: usize,
2616 sev_moderate: usize,
2617 loaded_baseline: Option<HealthBaselineData>,
2618}
2619
2620struct CollectedHealthFindings {
2621 findings: Vec<ComplexityViolation>,
2622 files_analyzed: usize,
2623 total_functions: usize,
2624 complexity_ms: f64,
2625}
2626
2627struct HealthOutputContextInput<'a, R> {
2628 config: &'a ResolvedConfig,
2629 files: &'a [fallow_types::discover::DiscoveredFile],
2630 modules: &'a [crate::extract::ModuleInfo],
2631 scope: &'a HealthScope<'a, R>,
2632 needs_file_scores: bool,
2633 report_coverage_gaps: bool,
2634 has_istanbul_coverage: bool,
2635 findings_data: HealthFindingsData,
2636 analysis_data: HealthAnalysisData,
2637 derived_sections: HealthDerivedSections,
2638 vital_data: HealthVitalData,
2639 timings: HealthPipelineTimings,
2640 start: &'a Instant,
2641}
2642
2643struct HealthOutputContext<'a, R> {
2644 build: HealthOutputBuildInput<'a, R>,
2645 sections: HealthOutputSectionInput,
2646}
2647
2648struct HealthOutputBuildInput<'a, R> {
2649 config: &'a ResolvedConfig,
2650 files: &'a [fallow_types::discover::DiscoveredFile],
2651 modules: &'a [crate::extract::ModuleInfo],
2652 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2653 group_resolver: Option<&'a R>,
2654 needs_file_scores: bool,
2655 report_coverage_gaps: bool,
2656 has_istanbul_coverage: bool,
2657 threshold_overrides: Vec<fallow_output::ThresholdOverrideState>,
2658 max_cyclomatic: u16,
2659 max_cognitive: u16,
2660 max_crap: f64,
2661 files_analyzed: usize,
2662 total_functions: usize,
2663 total_above_threshold: usize,
2664 sev_critical: usize,
2665 sev_high: usize,
2666 sev_moderate: usize,
2667 timing_base: HealthTimingBaseInput,
2668 start: &'a Instant,
2669}
2670
2671struct HealthOutputSectionInput {
2672 analysis_data: HealthAnalysisData,
2673 derived_sections: HealthDerivedSections,
2674 vital_data: HealthVitalData,
2675 findings: Vec<ComplexityViolation>,
2676}
2677
2678struct HealthOutputParts {
2679 report: fallow_output::HealthReport,
2680 grouping: Option<fallow_output::HealthGrouping>,
2681 timings: Option<fallow_output::HealthTimings>,
2682 coverage_gaps_has_findings: bool,
2683}
2684
2685struct HealthOutputSupportingParts {
2686 grouping: Option<fallow_output::HealthGrouping>,
2687 timings: Option<fallow_output::HealthTimings>,
2688}
2689
2690fn prepare_health_output_context<R>(
2691 input: HealthOutputContextInput<'_, R>,
2692) -> HealthOutputContext<'_, R> {
2693 let HealthFindingsData {
2694 findings,
2695 threshold_overrides,
2696 files_analyzed,
2697 total_functions,
2698 complexity_ms,
2699 total_above_threshold,
2700 sev_critical,
2701 sev_high,
2702 sev_moderate,
2703 loaded_baseline: _,
2704 } = input.findings_data;
2705
2706 HealthOutputContext {
2707 build: HealthOutputBuildInput {
2708 config: input.config,
2709 files: input.files,
2710 modules: input.modules,
2711 file_paths: &input.scope.file_paths,
2712 group_resolver: input.scope.group_resolver.as_ref(),
2713 needs_file_scores: input.needs_file_scores,
2714 report_coverage_gaps: input.report_coverage_gaps,
2715 has_istanbul_coverage: input.has_istanbul_coverage,
2716 threshold_overrides,
2717 max_cyclomatic: input.scope.max_cyclomatic,
2718 max_cognitive: input.scope.max_cognitive,
2719 max_crap: input.scope.max_crap,
2720 files_analyzed,
2721 total_functions,
2722 total_above_threshold,
2723 sev_critical,
2724 sev_high,
2725 sev_moderate,
2726 timing_base: input.timings.into_base_input(complexity_ms),
2727 start: input.start,
2728 },
2729 sections: HealthOutputSectionInput {
2730 analysis_data: input.analysis_data,
2731 derived_sections: input.derived_sections,
2732 vital_data: input.vital_data,
2733 findings,
2734 },
2735 }
2736}
2737
2738fn build_health_output_parts<R: super::HealthGroupResolver>(
2739 opts: &HealthOptions<'_>,
2740 build: &HealthOutputBuildInput<'_, R>,
2741 sections: HealthOutputSectionInput,
2742) -> HealthOutputParts {
2743 let HealthOutputSectionInput {
2744 analysis_data,
2745 derived_sections,
2746 vital_data,
2747 findings,
2748 } = sections;
2749 let coverage_gaps_has_findings =
2750 health_coverage_gaps_has_findings(analysis_data.score_output.as_ref());
2751 let action_ctx = build_health_action_context(
2752 opts,
2753 build.config,
2754 build.max_cyclomatic,
2755 build.max_cognitive,
2756 build.max_crap,
2757 );
2758
2759 let HealthOutputSupportingParts { grouping, timings } =
2760 build_health_supporting_parts(HealthSupportingPartsInput {
2761 opts,
2762 build,
2763 analysis_data: &analysis_data,
2764 derived_sections: &derived_sections,
2765 vital_data: &vital_data,
2766 findings: &findings,
2767 action_ctx: &action_ctx,
2768 });
2769
2770 let framework_health =
2771 build_framework_health_diagnostics(build.config, analysis_data.framework_health_facts);
2772
2773 let report = build_health_report_from_pipeline(
2774 opts,
2775 &action_ctx,
2776 build_health_report_pipeline_input(
2777 build,
2778 analysis_data,
2779 vital_data,
2780 derived_sections,
2781 findings,
2782 framework_health,
2783 ),
2784 );
2785
2786 HealthOutputParts {
2787 report,
2788 grouping,
2789 timings,
2790 coverage_gaps_has_findings,
2791 }
2792}
2793
2794fn build_health_report_pipeline_input<R>(
2795 build: &HealthOutputBuildInput<'_, R>,
2796 analysis_data: HealthAnalysisData,
2797 vital_data: HealthVitalData,
2798 derived_sections: HealthDerivedSections,
2799 findings: Vec<ComplexityViolation>,
2800 framework_health: Option<fallow_output::FrameworkHealthDiagnostics>,
2801) -> HealthReportPipelineInput {
2802 HealthReportPipelineInput {
2803 report_coverage_gaps: build.report_coverage_gaps,
2804 findings,
2805 threshold_overrides: build.threshold_overrides.clone(),
2806 files_analyzed: build.files_analyzed,
2807 total_functions: build.total_functions,
2808 total_above_threshold: build.total_above_threshold,
2809 max_cyclomatic: build.max_cyclomatic,
2810 max_cognitive: build.max_cognitive,
2811 max_crap: build.max_crap,
2812 analysis_data,
2813 vital_data,
2814 hotspots: derived_sections.hotspots,
2815 hotspot_summary: derived_sections.hotspot_summary,
2816 targets: derived_sections.targets,
2817 target_thresholds: derived_sections.target_thresholds,
2818 has_istanbul_coverage: build.has_istanbul_coverage,
2819 framework_health,
2820 sev_critical: build.sev_critical,
2821 sev_high: build.sev_high,
2822 sev_moderate: build.sev_moderate,
2823 }
2824}
2825
2826#[derive(Clone, Copy)]
2827struct HealthSupportingPartsInput<'a, R> {
2828 opts: &'a HealthOptions<'a>,
2829 build: &'a HealthOutputBuildInput<'a, R>,
2830 analysis_data: &'a HealthAnalysisData,
2831 derived_sections: &'a HealthDerivedSections,
2832 vital_data: &'a HealthVitalData,
2833 findings: &'a [ComplexityViolation],
2834 action_ctx: &'a fallow_output::HealthActionContext,
2835}
2836
2837#[expect(
2838 clippy::needless_pass_by_value,
2839 reason = "input is a Copy struct; by-value matches the original CLI signature"
2840)]
2841fn build_health_supporting_parts<R: super::HealthGroupResolver>(
2842 input: HealthSupportingPartsInput<'_, R>,
2843) -> HealthOutputSupportingParts {
2844 let grouping = build_health_output_grouping(&input);
2845 let timings = build_health_timings_from_pipeline(
2846 input.opts,
2847 input.build.start,
2848 input.analysis_data,
2849 input.derived_sections,
2850 &input.build.timing_base,
2851 );
2852
2853 HealthOutputSupportingParts { grouping, timings }
2854}
2855
2856fn build_health_output_grouping<R: super::HealthGroupResolver>(
2857 input: &HealthSupportingPartsInput<'_, R>,
2858) -> Option<fallow_output::HealthGrouping> {
2859 let file_scores = health_file_scores_slice(input.analysis_data.score_output.as_ref());
2860 build_health_grouping_from_context(HealthGroupingContextInput {
2861 opts: input.opts,
2862 config: input.build.config,
2863 group_resolver: input.build.group_resolver,
2864 candidate_paths: &input.derived_sections.candidate_paths,
2865 files: input.build.files,
2866 modules: input.build.modules,
2867 file_paths: input.build.file_paths,
2868 score_output: input.analysis_data.score_output.as_ref(),
2869 file_scores,
2870 findings: input.findings,
2871 hotspots: &input.derived_sections.hotspots,
2872 vital_data: input.vital_data,
2873 targets: &input.derived_sections.targets,
2874 needs_file_scores: input.build.needs_file_scores,
2875 action_ctx: input.action_ctx,
2876 })
2877}
2878
2879struct HealthDerivedSectionInput<'a> {
2880 config: &'a ResolvedConfig,
2881 files: &'a [fallow_types::discover::DiscoveredFile],
2882 ignore_set: &'a globset::GlobSet,
2883 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2884 ws_roots: Option<&'a [std::path::PathBuf]>,
2885 file_scores: &'a [FileHealthScore],
2886 churn_fetch: Option<hotspots::ChurnFetchResult>,
2887 diff_index: Option<&'a fallow_output::DiffIndex>,
2888 score_output: Option<&'a scoring::FileScoreOutput>,
2889 loaded_baseline: Option<&'a HealthBaselineData>,
2890}
2891
2892struct HealthDerivedSections {
2893 candidate_paths: rustc_hash::FxHashSet<std::path::PathBuf>,
2894 dupes_report: Option<crate::duplicates::DuplicationReport>,
2895 duplication_ms: f64,
2896 hotspots: Vec<HotspotEntry>,
2897 hotspot_summary: Option<HotspotSummary>,
2898 hotspots_ms: f64,
2899 targets: Vec<RefactoringTarget>,
2900 target_thresholds: Option<fallow_output::TargetThresholds>,
2901 targets_ms: f64,
2902}
2903
2904struct HealthReportPipelineInput {
2905 report_coverage_gaps: bool,
2906 findings: Vec<ComplexityViolation>,
2907 threshold_overrides: Vec<fallow_output::ThresholdOverrideState>,
2908 files_analyzed: usize,
2909 total_functions: usize,
2910 total_above_threshold: usize,
2911 max_cyclomatic: u16,
2912 max_cognitive: u16,
2913 max_crap: f64,
2914 analysis_data: HealthAnalysisData,
2915 vital_data: HealthVitalData,
2916 hotspots: Vec<HotspotEntry>,
2917 hotspot_summary: Option<HotspotSummary>,
2918 targets: Vec<RefactoringTarget>,
2919 target_thresholds: Option<fallow_output::TargetThresholds>,
2920 has_istanbul_coverage: bool,
2921 framework_health: Option<fallow_output::FrameworkHealthDiagnostics>,
2922 sev_critical: usize,
2923 sev_high: usize,
2924 sev_moderate: usize,
2925}
2926
2927fn build_health_report_from_pipeline(
2928 opts: &HealthOptions<'_>,
2929 action_ctx: &fallow_output::HealthActionContext,
2930 input: HealthReportPipelineInput,
2931) -> fallow_output::HealthReport {
2932 assemble_health_report(
2933 opts,
2934 action_ctx,
2935 HealthReportAssembly {
2936 report_coverage_gaps: input.report_coverage_gaps,
2937 findings: input.findings,
2938 threshold_overrides: input.threshold_overrides,
2939 files_analyzed: input.files_analyzed,
2940 total_functions: input.total_functions,
2941 total_above_threshold: input.total_above_threshold,
2942 max_cyclomatic: input.max_cyclomatic,
2943 max_cognitive: input.max_cognitive,
2944 max_crap: input.max_crap,
2945 files_scored: input.analysis_data.files_scored,
2946 average_maintainability: input.analysis_data.average_maintainability,
2947 vital_signs: input.vital_data.vital_signs,
2948 health_score: input.vital_data.health_score,
2949 score_output: input.analysis_data.score_output,
2950 hotspots: input.hotspots,
2951 hotspot_summary: input.hotspot_summary,
2952 targets: input.targets,
2953 target_thresholds: input.target_thresholds,
2954 health_trend: input.vital_data.health_trend,
2955 has_istanbul_coverage: input.has_istanbul_coverage,
2956 runtime_coverage: input.analysis_data.runtime_coverage,
2957 framework_health: input.framework_health,
2958 large_functions: input.vital_data.large_functions,
2959 sev_critical: input.sev_critical,
2960 sev_high: input.sev_high,
2961 sev_moderate: input.sev_moderate,
2962 },
2963 )
2964}
2965
2966#[derive(Debug, Clone, Copy)]
2967struct GlobalHealthThresholds {
2968 cyclomatic: u16,
2969 cognitive: u16,
2970 crap: f64,
2971}
2972
2973#[derive(Debug, Clone, Copy)]
2974struct AppliedHealthThresholds {
2975 effective: fallow_output::HealthEffectiveThresholds,
2976 override_index: Option<usize>,
2977}
2978
2979struct CompiledThresholdOverride {
2980 index: usize,
2981 matchers: globset::GlobSet,
2982 functions: Vec<String>,
2983 configured: fallow_output::HealthConfiguredThresholds,
2984 reason: Option<String>,
2985}
2986
2987struct ThresholdOverrideMatch<'a> {
2988 entry: &'a CompiledThresholdOverride,
2989 effective: fallow_output::HealthEffectiveThresholds,
2990}
2991
2992struct ThresholdOverrideResolver {
2993 entries: Vec<CompiledThresholdOverride>,
2994 global: GlobalHealthThresholds,
2995}
2996
2997impl ThresholdOverrideResolver {
2998 #[must_use]
2999 fn new(
3000 overrides: &[fallow_config::HealthThresholdOverride],
3001 global: GlobalHealthThresholds,
3002 ) -> Self {
3003 let entries = overrides
3004 .iter()
3005 .enumerate()
3006 .map(|(index, override_entry)| {
3007 let mut builder = globset::GlobSetBuilder::new();
3008 for pattern in &override_entry.files {
3009 if let Ok(glob) = globset::Glob::new(pattern) {
3010 builder.add(glob);
3011 }
3012 }
3013 CompiledThresholdOverride {
3014 index,
3015 matchers: builder
3016 .build()
3017 .unwrap_or_else(|_| globset::GlobSet::empty()),
3018 functions: override_entry.functions.clone(),
3019 configured: fallow_output::HealthConfiguredThresholds {
3020 max_cyclomatic: override_entry.max_cyclomatic,
3021 max_cognitive: override_entry.max_cognitive,
3022 max_crap: override_entry.max_crap,
3023 },
3024 reason: override_entry.reason.clone(),
3025 }
3026 })
3027 .collect();
3028 Self { entries, global }
3029 }
3030
3031 #[must_use]
3032 fn resolve(
3033 &self,
3034 relative: &std::path::Path,
3035 function: &str,
3036 ) -> (AppliedHealthThresholds, Vec<ThresholdOverrideMatch<'_>>) {
3037 let mut effective = fallow_output::HealthEffectiveThresholds {
3038 max_cyclomatic: self.global.cyclomatic,
3039 max_cognitive: self.global.cognitive,
3040 max_crap: self.global.crap,
3041 };
3042 let mut override_index = None;
3043 let mut matches = Vec::new();
3044
3045 for entry in &self.entries {
3046 if !entry.matchers.is_match(relative) {
3047 continue;
3048 }
3049 if !entry.functions.is_empty() && !entry.functions.iter().any(|f| f == function) {
3050 continue;
3051 }
3052 if let Some(max_cyclomatic) = entry.configured.max_cyclomatic {
3053 effective.max_cyclomatic = max_cyclomatic;
3054 override_index = Some(entry.index);
3055 }
3056 if let Some(max_cognitive) = entry.configured.max_cognitive {
3057 effective.max_cognitive = max_cognitive;
3058 override_index = Some(entry.index);
3059 }
3060 if let Some(max_crap) = entry.configured.max_crap {
3061 effective.max_crap = max_crap;
3062 override_index = Some(entry.index);
3063 }
3064 matches.push(ThresholdOverrideMatch { entry, effective });
3065 }
3066
3067 (
3068 AppliedHealthThresholds {
3069 effective,
3070 override_index,
3071 },
3072 matches,
3073 )
3074 }
3075
3076 fn entries(&self) -> &[CompiledThresholdOverride] {
3077 &self.entries
3078 }
3079}
3080
3081#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3082enum ThresholdOverrideDimension {
3083 Complexity,
3084 Crap,
3085}
3086
3087#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3088struct ThresholdOverrideStateKey {
3089 status: &'static str,
3090 override_index: usize,
3091 path: Option<std::path::PathBuf>,
3092 function: Option<String>,
3093 dimension: ThresholdOverrideDimension,
3094}
3095
3096#[derive(Debug, Clone, Copy)]
3097struct MeasuredThresholdMetrics {
3098 cyclomatic: u16,
3099 cognitive: u16,
3100 crap: f64,
3101}
3102
3103#[derive(Default)]
3104struct ThresholdOverrideStateTracker {
3105 matched_indexes: rustc_hash::FxHashSet<usize>,
3106 seen: rustc_hash::FxHashSet<ThresholdOverrideStateKey>,
3107 states: Vec<fallow_output::ThresholdOverrideState>,
3108}
3109
3110impl ThresholdOverrideStateTracker {
3111 fn record_complexity(
3112 &mut self,
3113 function: ComplexityFunctionContext<'_>,
3114 matches: &[ThresholdOverrideMatch<'_>],
3115 global: GlobalHealthThresholds,
3116 ) {
3117 let ComplexityFunctionContext {
3118 path,
3119 function,
3120 cyclomatic,
3121 cognitive,
3122 } = function;
3123 for matched in matches {
3124 self.matched_indexes.insert(matched.entry.index);
3125 let configured = matched.entry.configured;
3126 let has_complexity_threshold =
3127 configured.max_cyclomatic.is_some() || configured.max_cognitive.is_some();
3128 if !has_complexity_threshold {
3129 continue;
3130 }
3131 let global_exceeded = configured
3132 .max_cyclomatic
3133 .is_some_and(|_| cyclomatic > global.cyclomatic)
3134 || configured
3135 .max_cognitive
3136 .is_some_and(|_| cognitive > global.cognitive);
3137 let local_exceeded = configured
3138 .max_cyclomatic
3139 .is_some_and(|threshold| cyclomatic > threshold)
3140 || configured
3141 .max_cognitive
3142 .is_some_and(|threshold| cognitive > threshold);
3143 let status = if global_exceeded && !local_exceeded {
3144 fallow_output::ThresholdOverrideStatus::Active
3145 } else if !global_exceeded {
3146 fallow_output::ThresholdOverrideStatus::Stale
3147 } else {
3148 continue;
3149 };
3150 self.push_state(ThresholdOverrideStateInput {
3151 status,
3152 override_index: matched.entry.index,
3153 path: Some(path.to_path_buf()),
3154 function: Some(function.to_string()),
3155 configured_thresholds: configured,
3156 effective_thresholds: matched.effective,
3157 metrics: Some(fallow_output::ThresholdOverrideMetrics {
3158 cyclomatic,
3159 cognitive,
3160 crap: None,
3161 }),
3162 reason: matched.entry.reason.clone(),
3163 dimension: ThresholdOverrideDimension::Complexity,
3164 });
3165 }
3166 }
3167
3168 fn record_crap(
3169 &mut self,
3170 path: &std::path::Path,
3171 function: &str,
3172 metrics: MeasuredThresholdMetrics,
3173 matches: &[ThresholdOverrideMatch<'_>],
3174 global: GlobalHealthThresholds,
3175 ) {
3176 for matched in matches {
3177 self.matched_indexes.insert(matched.entry.index);
3178 let Some(max_crap) = matched.entry.configured.max_crap else {
3179 continue;
3180 };
3181 let status = if metrics.crap >= global.crap && metrics.crap < max_crap {
3182 fallow_output::ThresholdOverrideStatus::Active
3183 } else if metrics.crap < global.crap {
3184 fallow_output::ThresholdOverrideStatus::Stale
3185 } else {
3186 continue;
3187 };
3188 self.push_state(ThresholdOverrideStateInput {
3189 status,
3190 override_index: matched.entry.index,
3191 path: Some(path.to_path_buf()),
3192 function: Some(function.to_string()),
3193 configured_thresholds: matched.entry.configured,
3194 effective_thresholds: matched.effective,
3195 metrics: Some(fallow_output::ThresholdOverrideMetrics {
3196 cyclomatic: metrics.cyclomatic,
3197 cognitive: metrics.cognitive,
3198 crap: Some(metrics.crap),
3199 }),
3200 reason: matched.entry.reason.clone(),
3201 dimension: ThresholdOverrideDimension::Crap,
3202 });
3203 }
3204 }
3205
3206 fn record_no_match_entries(&mut self, resolver: &ThresholdOverrideResolver, should_emit: bool) {
3207 if !should_emit {
3208 return;
3209 }
3210 for entry in resolver.entries() {
3211 if self.matched_indexes.contains(&entry.index) {
3212 continue;
3213 }
3214 self.push_state(ThresholdOverrideStateInput {
3215 status: fallow_output::ThresholdOverrideStatus::NoMatch,
3216 override_index: entry.index,
3217 path: None,
3218 function: None,
3219 configured_thresholds: entry.configured,
3220 effective_thresholds: fallow_output::HealthEffectiveThresholds {
3221 max_cyclomatic: entry
3222 .configured
3223 .max_cyclomatic
3224 .unwrap_or(resolver.global.cyclomatic),
3225 max_cognitive: entry
3226 .configured
3227 .max_cognitive
3228 .unwrap_or(resolver.global.cognitive),
3229 max_crap: entry.configured.max_crap.unwrap_or(resolver.global.crap),
3230 },
3231 metrics: None,
3232 reason: entry.reason.clone(),
3233 dimension: ThresholdOverrideDimension::Complexity,
3234 });
3235 }
3236 }
3237
3238 fn into_states(mut self) -> Vec<fallow_output::ThresholdOverrideState> {
3239 self.states.sort_by(|a, b| {
3240 a.override_index
3241 .cmp(&b.override_index)
3242 .then(a.path.cmp(&b.path))
3243 .then(a.function.cmp(&b.function))
3244 });
3245 self.states
3246 }
3247
3248 fn push_state(&mut self, input: ThresholdOverrideStateInput) {
3249 let status_key = match input.status {
3250 fallow_output::ThresholdOverrideStatus::Active => "active",
3251 fallow_output::ThresholdOverrideStatus::Stale => "stale",
3252 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
3253 };
3254 let key = ThresholdOverrideStateKey {
3255 status: status_key,
3256 override_index: input.override_index,
3257 path: input.path.clone(),
3258 function: input.function.clone(),
3259 dimension: input.dimension,
3260 };
3261 if !self.seen.insert(key) {
3262 return;
3263 }
3264 self.states.push(fallow_output::ThresholdOverrideState {
3265 status: input.status,
3266 override_index: input.override_index,
3267 path: input.path,
3268 function: input.function,
3269 configured_thresholds: input.configured_thresholds,
3270 effective_thresholds: input.effective_thresholds,
3271 metrics: input.metrics,
3272 reason: input.reason,
3273 });
3274 }
3275}
3276
3277#[derive(Clone, Copy)]
3281struct ComplexityFunctionContext<'a> {
3282 path: &'a std::path::Path,
3283 function: &'a str,
3284 cyclomatic: u16,
3285 cognitive: u16,
3286}
3287
3288struct ThresholdOverrideStateInput {
3289 status: fallow_output::ThresholdOverrideStatus,
3290 override_index: usize,
3291 path: Option<std::path::PathBuf>,
3292 function: Option<String>,
3293 configured_thresholds: fallow_output::HealthConfiguredThresholds,
3294 effective_thresholds: fallow_output::HealthEffectiveThresholds,
3295 metrics: Option<fallow_output::ThresholdOverrideMetrics>,
3296 reason: Option<String>,
3297 dimension: ThresholdOverrideDimension,
3298}
3299
3300#[derive(Clone, Copy)]
3301struct HealthGroupingContextInput<'a, R> {
3302 opts: &'a HealthOptions<'a>,
3303 config: &'a ResolvedConfig,
3304 group_resolver: Option<&'a R>,
3305 candidate_paths: &'a rustc_hash::FxHashSet<std::path::PathBuf>,
3306 files: &'a [fallow_types::discover::DiscoveredFile],
3307 modules: &'a [crate::extract::ModuleInfo],
3308 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
3309 score_output: Option<&'a scoring::FileScoreOutput>,
3310 file_scores: &'a [FileHealthScore],
3311 findings: &'a [ComplexityViolation],
3312 hotspots: &'a [HotspotEntry],
3313 vital_data: &'a HealthVitalData,
3314 targets: &'a [RefactoringTarget],
3315 needs_file_scores: bool,
3316 action_ctx: &'a fallow_output::HealthActionContext,
3317}
3318
3319#[expect(
3320 clippy::needless_pass_by_value,
3321 reason = "input is a Copy struct; by-value matches the original CLI signature"
3322)]
3323fn build_health_grouping_from_context<R: super::HealthGroupResolver>(
3324 input: HealthGroupingContextInput<'_, R>,
3325) -> Option<fallow_output::HealthGrouping> {
3326 build_optional_health_grouping_opt(
3327 input.group_resolver,
3328 &input.config.root,
3329 input.candidate_paths,
3330 &grouping::HealthGroupingInput {
3331 files: input.files,
3332 modules: input.modules,
3333 file_paths: input.file_paths,
3334 score_output: input.score_output,
3335 file_scores: input.file_scores,
3336 findings: input.findings,
3337 hotspots: input.hotspots,
3338 large_functions: &input.vital_data.large_functions,
3339 targets: input.targets,
3340 score_requested: input.opts.score,
3341 duplicates_config: input.opts.score.then_some(&input.config.duplicates),
3342 needs_file_scores: input.needs_file_scores,
3343 needs_hotspots: input.opts.hotspots || input.opts.targets,
3344 show_vital_signs: !input.opts.score_only_output,
3345 action_ctx: input.action_ctx,
3346 },
3347 )
3348}
3349
3350fn needs_health_file_scores(
3351 opts: &HealthOptions<'_>,
3352 report_coverage_gaps: bool,
3353 enforce_coverage_gaps: bool,
3354 enforce_crap: bool,
3355) -> bool {
3356 opts.file_scores
3357 || report_coverage_gaps
3358 || enforce_coverage_gaps
3359 || opts.hotspots
3360 || opts.targets
3361 || opts.force_full
3362 || enforce_crap
3363}
3364
3365fn health_coverage_gaps_has_findings(score_output: Option<&scoring::FileScoreOutput>) -> bool {
3366 score_output.is_some_and(|output| !output.coverage.report.is_empty())
3367}
3368
3369fn health_file_scores_slice(score_output: Option<&scoring::FileScoreOutput>) -> &[FileHealthScore] {
3370 score_output.map_or(&[] as &[_], |output| output.scores.as_slice())
3371}
3372
3373fn prepare_health_derived_sections(
3374 opts: &HealthOptions<'_>,
3375 input: HealthDerivedSectionInput<'_>,
3376) -> HealthDerivedSections {
3377 let (candidate_paths, dupes_report, duplication_ms) =
3378 prepare_health_section_dupes(opts, &input);
3379 let (hotspots, hotspot_summary, hotspots_ms) = prepare_health_section_hotspots(
3380 opts,
3381 HealthHotspotSectionInput {
3382 config: input.config,
3383 file_scores: input.file_scores,
3384 ignore_set: input.ignore_set,
3385 ws_roots: input.ws_roots,
3386 churn_fetch: input.churn_fetch,
3387 diff_index: input.diff_index,
3388 },
3389 );
3390 let (targets, target_thresholds, targets_ms) = prepare_health_section_targets(
3391 opts,
3392 &HealthTargetSectionInput {
3393 score_output: input.score_output,
3394 file_scores: input.file_scores,
3395 hotspots: &hotspots,
3396 loaded_baseline: input.loaded_baseline,
3397 config: input.config,
3398 diff_index: input.diff_index,
3399 dupes_report: dupes_report.as_ref(),
3400 },
3401 );
3402
3403 HealthDerivedSections {
3404 candidate_paths,
3405 dupes_report,
3406 duplication_ms,
3407 hotspots,
3408 hotspot_summary,
3409 hotspots_ms,
3410 targets,
3411 target_thresholds,
3412 targets_ms,
3413 }
3414}
3415
3416fn prepare_health_section_dupes(
3417 opts: &HealthOptions<'_>,
3418 input: &HealthDerivedSectionInput<'_>,
3419) -> (
3420 rustc_hash::FxHashSet<std::path::PathBuf>,
3421 Option<crate::duplicates::DuplicationReport>,
3422 f64,
3423) {
3424 prepare_health_duplication_data(
3425 opts,
3426 input.config,
3427 input.files,
3428 input.changed_files,
3429 input.ws_roots,
3430 input.ignore_set,
3431 )
3432}
3433
3434struct HealthHotspotSectionInput<'a> {
3435 config: &'a ResolvedConfig,
3436 file_scores: &'a [FileHealthScore],
3437 ignore_set: &'a globset::GlobSet,
3438 ws_roots: Option<&'a [std::path::PathBuf]>,
3439 churn_fetch: Option<hotspots::ChurnFetchResult>,
3440 diff_index: Option<&'a fallow_output::DiffIndex>,
3441}
3442
3443fn prepare_health_section_hotspots(
3444 opts: &HealthOptions<'_>,
3445 input: HealthHotspotSectionInput<'_>,
3446) -> (Vec<HotspotEntry>, Option<HotspotSummary>, f64) {
3447 compute_filtered_hotspots(FilteredHotspotInput {
3448 opts,
3449 config: input.config,
3450 file_scores_slice: input.file_scores,
3451 ignore_set: input.ignore_set,
3452 ws_roots: input.ws_roots,
3453 churn_fetch: input.churn_fetch,
3454 diff_index: input.diff_index,
3455 })
3456}
3457
3458struct HealthTargetSectionInput<'a> {
3459 score_output: Option<&'a scoring::FileScoreOutput>,
3460 file_scores: &'a [FileHealthScore],
3461 hotspots: &'a [HotspotEntry],
3462 loaded_baseline: Option<&'a HealthBaselineData>,
3463 config: &'a ResolvedConfig,
3464 diff_index: Option<&'a fallow_output::DiffIndex>,
3465 dupes_report: Option<&'a crate::duplicates::DuplicationReport>,
3466}
3467
3468fn prepare_health_section_targets(
3469 opts: &HealthOptions<'_>,
3470 input: &HealthTargetSectionInput<'_>,
3471) -> (Vec<RefactoringTarget>, Option<TargetThresholds>, f64) {
3472 compute_filtered_targets(FilteredTargetInput {
3473 opts,
3474 score_output: input.score_output,
3475 file_scores_slice: input.file_scores,
3476 hotspots: input.hotspots,
3477 loaded_baseline: input.loaded_baseline,
3478 config: input.config,
3479 diff_index: input.diff_index,
3480 dupes_report: input.dupes_report,
3481 })
3482}
3483
3484struct HealthTimingInput {
3485 config_ms: f64,
3486 discover_ms: f64,
3487 parse_ms: f64,
3488 parse_cpu_ms: f64,
3489 complexity_ms: f64,
3490 file_scores_ms: f64,
3491 git_churn_ms: f64,
3492 git_churn_cache_hit: bool,
3493 hotspots_ms: f64,
3494 duplication_ms: f64,
3495 targets_ms: f64,
3496 shared_parse: bool,
3497}
3498
3499struct HealthTimingBaseInput {
3500 config_ms: f64,
3501 discover_ms: f64,
3502 parse_ms: f64,
3503 parse_cpu_ms: f64,
3504 complexity_ms: f64,
3505 shared_parse: bool,
3506}
3507
3508struct HealthResultInput<R> {
3509 config: ResolvedConfig,
3510 report: fallow_output::HealthReport,
3511 grouping: Option<fallow_output::HealthGrouping>,
3512 group_resolver: Option<R>,
3513 elapsed: Duration,
3514 timings: Option<fallow_output::HealthTimings>,
3515 coverage_gaps_has_findings: bool,
3516 should_fail_on_coverage_gaps: bool,
3517}
3518
3519fn build_health_result<R>(input: HealthResultInput<R>) -> HealthResultGeneric<R> {
3520 let HealthResultInput {
3521 config,
3522 report,
3523 grouping,
3524 group_resolver,
3525 elapsed,
3526 timings,
3527 coverage_gaps_has_findings,
3528 should_fail_on_coverage_gaps,
3529 } = input;
3530
3531 HealthResultGeneric {
3532 report,
3533 grouping,
3534 group_resolver,
3535 config,
3536 elapsed,
3537 timings,
3538 coverage_gaps_has_findings,
3539 should_fail_on_coverage_gaps,
3540 }
3541}
3542
3543#[derive(Clone, Copy)]
3544struct HealthFindingsInput<'a> {
3545 opts: &'a HealthOptions<'a>,
3546 config: &'a ResolvedConfig,
3547 modules: &'a [crate::extract::ModuleInfo],
3548 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
3549 ignore_set: &'a globset::GlobSet,
3550 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3551 ws_roots: Option<&'a [std::path::PathBuf]>,
3552 diff_index: Option<&'a fallow_output::DiffIndex>,
3553 max_cyclomatic: u16,
3554 max_cognitive: u16,
3555 max_crap: f64,
3556 enforce_crap: bool,
3557 score_output: Option<&'a scoring::FileScoreOutput>,
3558}
3559
3560fn prepare_health_findings(input: HealthFindingsInput<'_>) -> Result<HealthFindingsData, ExitCode> {
3561 let t = Instant::now();
3562 let global_thresholds = GlobalHealthThresholds {
3563 cyclomatic: input.max_cyclomatic,
3564 cognitive: input.max_cognitive,
3565 crap: input.max_crap,
3566 };
3567 let threshold_resolver =
3568 ThresholdOverrideResolver::new(&input.config.health.threshold_overrides, global_thresholds);
3569 let mut threshold_state_tracker = ThresholdOverrideStateTracker::default();
3570 let mut collected =
3571 collect_health_findings(input, &threshold_resolver, &mut threshold_state_tracker, t);
3572
3573 let mut crap_ctx = HealthCrapMergeContext {
3574 modules: input.modules,
3575 file_paths: input.file_paths,
3576 ignore_set: input.ignore_set,
3577 changed_files: input.changed_files,
3578 ws_roots: input.ws_roots,
3579 max_cyclomatic: input.max_cyclomatic,
3580 max_cognitive: input.max_cognitive,
3581 enforce_crap: input.enforce_crap,
3582 score_output: input.score_output,
3583 config_root: &input.config.root,
3584 threshold_resolver: &threshold_resolver,
3585 threshold_state_tracker: &mut threshold_state_tracker,
3586 };
3587 apply_optional_crap_findings(input.opts, &mut collected.findings, &mut crap_ctx);
3588 let (total_above_threshold, sev_critical, sev_high, sev_moderate, loaded_baseline) =
3589 finalize_health_findings(
3590 input.opts,
3591 input.config,
3592 &mut collected.findings,
3593 input.diff_index,
3594 )?;
3595 threshold_state_tracker.record_no_match_entries(
3596 &threshold_resolver,
3597 should_emit_no_match_threshold_overrides(
3598 input.opts,
3599 input.changed_files,
3600 input.ws_roots,
3601 input.diff_index,
3602 ),
3603 );
3604
3605 Ok(HealthFindingsData {
3606 findings: collected.findings,
3607 threshold_overrides: threshold_state_tracker.into_states(),
3608 files_analyzed: collected.files_analyzed,
3609 total_functions: collected.total_functions,
3610 complexity_ms: collected.complexity_ms,
3611 total_above_threshold,
3612 sev_critical,
3613 sev_high,
3614 sev_moderate,
3615 loaded_baseline,
3616 })
3617}
3618
3619fn collect_health_findings(
3620 input: HealthFindingsInput<'_>,
3621 threshold_resolver: &ThresholdOverrideResolver,
3622 threshold_state_tracker: &mut ThresholdOverrideStateTracker,
3623 started_at: Instant,
3624) -> CollectedHealthFindings {
3625 let mut collect_input = CollectFindingsInput {
3626 modules: input.modules,
3627 file_paths: input.file_paths,
3628 config_root: &input.config.root,
3629 ignore_set: input.ignore_set,
3630 changed_files: input.changed_files,
3631 ws_roots: input.ws_roots,
3632 threshold_resolver,
3633 threshold_state_tracker,
3634 complexity_breakdown: input.opts.complexity_breakdown,
3635 };
3636 let (findings, files_analyzed, total_functions) =
3637 collect_findings_with_resolver(&mut collect_input);
3638
3639 CollectedHealthFindings {
3640 findings,
3641 files_analyzed,
3642 total_functions,
3643 complexity_ms: started_at.elapsed().as_secs_f64() * 1000.0,
3644 }
3645}
3646
3647struct HealthCrapMergeContext<'a> {
3648 modules: &'a [crate::extract::ModuleInfo],
3649 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
3650 ignore_set: &'a globset::GlobSet,
3651 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3652 ws_roots: Option<&'a [std::path::PathBuf]>,
3653 max_cyclomatic: u16,
3654 max_cognitive: u16,
3655 enforce_crap: bool,
3656 score_output: Option<&'a scoring::FileScoreOutput>,
3657 config_root: &'a std::path::Path,
3658 threshold_resolver: &'a ThresholdOverrideResolver,
3659 threshold_state_tracker: &'a mut ThresholdOverrideStateTracker,
3660}
3661
3662fn apply_optional_crap_findings(
3663 opts: &HealthOptions<'_>,
3664 findings: &mut Vec<ComplexityViolation>,
3665 ctx: &mut HealthCrapMergeContext<'_>,
3666) {
3667 if ctx.enforce_crap
3668 && let Some(score_out) = ctx.score_output
3669 {
3670 let mut input = CrapFindingMergeInput {
3671 modules: ctx.modules,
3672 file_paths: ctx.file_paths,
3673 config_root: ctx.config_root,
3674 ignore_set: ctx.ignore_set,
3675 changed_files: ctx.changed_files,
3676 ws_roots: ctx.ws_roots,
3677 per_function_crap: &score_out.per_function_crap,
3678 template_inherit_provenance: &score_out.template_inherit_provenance,
3679 complexity_breakdown: opts.complexity_breakdown,
3680 threshold_resolver: ctx.threshold_resolver,
3681 threshold_state_tracker: ctx.threshold_state_tracker,
3682 };
3683 merge_crap_findings(findings, &mut input);
3684 }
3685 append_component_rollup_findings(
3686 findings,
3687 ctx.score_output
3688 .map(|output| &output.template_inherit_provenance),
3689 ctx.max_cyclomatic,
3690 ctx.max_cognitive,
3691 );
3692}
3693
3694fn should_emit_no_match_threshold_overrides(
3695 opts: &HealthOptions<'_>,
3696 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
3697 ws_roots: Option<&[std::path::PathBuf]>,
3698 diff_index: Option<&fallow_output::DiffIndex>,
3699) -> bool {
3700 opts.changed_since.is_none()
3701 && opts.diff_index.is_none()
3702 && !opts.use_shared_diff_index
3703 && opts.workspace.is_none()
3704 && opts.changed_workspaces.is_none()
3705 && changed_files.is_none()
3706 && ws_roots.is_none()
3707 && diff_index.is_none()
3708}
3709
3710type HealthFindingFinalizeResult = (usize, usize, usize, usize, Option<HealthBaselineData>);
3711
3712fn finalize_health_findings(
3713 opts: &HealthOptions<'_>,
3714 config: &ResolvedConfig,
3715 findings: &mut Vec<ComplexityViolation>,
3716 diff_index: Option<&fallow_output::DiffIndex>,
3717) -> Result<HealthFindingFinalizeResult, ExitCode> {
3718 if let Some(diff_index) = diff_index {
3719 filter_complexity_findings_by_diff(findings, diff_index, &config.root);
3720 }
3721 sort_findings(findings, opts.sort);
3722 let total_above_threshold = findings.len();
3723 let (sev_critical, sev_high, sev_moderate) = count_finding_severities(findings);
3724 let loaded_baseline = apply_health_baseline_and_top(opts, config, findings)?;
3725 Ok((
3726 total_above_threshold,
3727 sev_critical,
3728 sev_high,
3729 sev_moderate,
3730 loaded_baseline,
3731 ))
3732}
3733
3734fn build_health_timings_from_pipeline(
3735 opts: &HealthOptions<'_>,
3736 start: &Instant,
3737 analysis_data: &HealthAnalysisData,
3738 sections: &HealthDerivedSections,
3739 input: &HealthTimingBaseInput,
3740) -> Option<HealthTimings> {
3741 build_health_timings(
3742 opts,
3743 start,
3744 &HealthTimingInput {
3745 config_ms: input.config_ms,
3746 discover_ms: input.discover_ms,
3747 parse_ms: input.parse_ms,
3748 parse_cpu_ms: input.parse_cpu_ms,
3749 complexity_ms: input.complexity_ms,
3750 file_scores_ms: analysis_data.file_scores_ms,
3751 git_churn_ms: analysis_data.git_churn_ms,
3752 git_churn_cache_hit: analysis_data.git_churn_cache_hit,
3753 hotspots_ms: sections.hotspots_ms,
3754 duplication_ms: sections.duplication_ms,
3755 targets_ms: sections.targets_ms,
3756 shared_parse: input.shared_parse,
3757 },
3758 )
3759}
3760
3761fn build_health_timings(
3762 opts: &HealthOptions<'_>,
3763 start: &Instant,
3764 input: &HealthTimingInput,
3765) -> Option<HealthTimings> {
3766 if !opts.performance {
3767 return None;
3768 }
3769
3770 let inner_ms = start.elapsed().as_secs_f64() * 1000.0;
3771 let total_ms = input.config_ms + input.discover_ms + input.parse_ms + inner_ms;
3772 Some(HealthTimings {
3773 config_ms: input.config_ms,
3774 discover_ms: input.discover_ms,
3775 parse_ms: input.parse_ms,
3776 parse_cpu_ms: input.parse_cpu_ms,
3777 complexity_ms: input.complexity_ms,
3778 file_scores_ms: input.file_scores_ms,
3779 git_churn_ms: input.git_churn_ms,
3780 git_churn_cache_hit: input.git_churn_cache_hit,
3781 hotspots_ms: input.hotspots_ms,
3782 duplication_ms: input.duplication_ms,
3783 targets_ms: input.targets_ms,
3784 total_ms,
3785 shared_parse: input.shared_parse,
3786 })
3787}
3788
3789fn prepare_health_coverage_settings(
3790 opts: &HealthOptions<'_>,
3791 config: &ResolvedConfig,
3792) -> Result<HealthCoverageSettings, ExitCode> {
3793 let config_coverage_enabled = config.rules.coverage_gaps != fallow_config::Severity::Off;
3794 let report_coverage_gaps =
3795 opts.coverage_gaps || (opts.config_activates_coverage_gaps && config_coverage_enabled);
3796 let enforce_coverage_gaps = opts.enforce_coverage_gap_gate
3797 && config.rules.coverage_gaps == fallow_config::Severity::Error;
3798 let istanbul_coverage = load_health_coverage(opts, config)?;
3799
3800 Ok(HealthCoverageSettings {
3801 report_coverage_gaps,
3802 enforce_coverage_gaps,
3803 istanbul_coverage,
3804 })
3805}
3806
3807fn build_optional_health_grouping_opt<R: super::HealthGroupResolver>(
3808 resolver: Option<&R>,
3809 project_root: &std::path::Path,
3810 candidate_paths: &rustc_hash::FxHashSet<std::path::PathBuf>,
3811 input: &grouping::HealthGroupingInput<'_>,
3812) -> Option<HealthGrouping> {
3813 let resolver = resolver?;
3814 Some(grouping::build_health_grouping(
3815 resolver as &dyn super::HealthGroupResolver,
3816 project_root,
3817 candidate_paths,
3818 input,
3819 ))
3820}
3821
3822fn active_health_coverage_model(has_istanbul_coverage: bool) -> fallow_output::CoverageModel {
3823 if has_istanbul_coverage {
3824 fallow_output::CoverageModel::Istanbul
3825 } else {
3826 fallow_output::CoverageModel::StaticEstimated
3827 }
3828}
3829
3830fn build_health_action_context(
3831 opts: &HealthOptions<'_>,
3832 config: &ResolvedConfig,
3833 max_cyclomatic: u16,
3834 max_cognitive: u16,
3835 max_crap: f64,
3836) -> fallow_output::HealthActionContext {
3837 let baseline_active = opts.baseline.is_some() || opts.save_baseline.is_some();
3838 let action_opts = if baseline_active {
3839 fallow_output::HealthActionOptions {
3840 omit_suppress_line: true,
3841 omit_reason: Some("baseline-active"),
3842 }
3843 } else if !config.health.suggest_inline_suppression {
3844 fallow_output::HealthActionOptions {
3845 omit_suppress_line: true,
3846 omit_reason: Some("config-disabled"),
3847 }
3848 } else {
3849 fallow_output::HealthActionOptions::default()
3850 };
3851 fallow_output::HealthActionContext {
3852 opts: action_opts,
3853 max_cyclomatic_threshold: max_cyclomatic,
3854 max_cognitive_threshold: max_cognitive,
3855 max_crap_threshold: max_crap,
3856 crap_refactor_band: config.health.crap_refactor_band,
3857 }
3858}
3859
3860fn prepare_health_scope<'a, R>(
3861 opts: &HealthOptions<'a>,
3862 config: &ResolvedConfig,
3863 files: &'a [fallow_types::discover::DiscoveredFile],
3864 scope_inputs: HealthScopeInputs<'a, R>,
3865) -> HealthScope<'a, R> {
3866 let max_cyclomatic = opts
3867 .thresholds
3868 .max_cyclomatic
3869 .unwrap_or(config.health.max_cyclomatic);
3870 let max_cognitive = opts
3871 .thresholds
3872 .max_cognitive
3873 .unwrap_or(config.health.max_cognitive);
3874 let max_crap = opts.thresholds.max_crap.unwrap_or(config.health.max_crap);
3875 let ignore_set = build_ignore_set(&config.health.ignore);
3876 let HealthScopeInputs {
3877 changed_files,
3878 diff_index,
3879 ws_roots,
3880 group_resolver,
3881 } = scope_inputs;
3882 let file_paths = files.iter().map(|f| (f.id, &f.path)).collect();
3883
3884 HealthScope {
3885 max_cyclomatic,
3886 max_cognitive,
3887 max_crap,
3888 enforce_crap: max_crap > 0.0,
3889 ignore_set,
3890 changed_files,
3891 diff_index,
3892 ws_roots,
3893 group_resolver,
3894 file_paths,
3895 }
3896}
3897
3898fn load_health_coverage(
3899 opts: &HealthOptions<'_>,
3900 config: &ResolvedConfig,
3901) -> Result<Option<scoring::IstanbulCoverage>, ExitCode> {
3902 if let Some(coverage_path) = opts.coverage_inputs.coverage {
3903 return scoring::load_istanbul_coverage(
3904 coverage_path,
3905 opts.coverage_inputs.coverage_root,
3906 Some(&config.root),
3907 )
3908 .map(Some)
3909 .map_err(|e| {
3910 emit_error(&format!("coverage: {e}"), 2, opts.output);
3911 ExitCode::from(2)
3912 });
3913 }
3914
3915 let Some(auto_path) = scoring::auto_detect_coverage(&config.root) else {
3916 return Ok(None);
3917 };
3918 if std::env::var("CI").is_ok_and(|v| !v.is_empty()) {
3919 eprintln!(
3920 "note: using auto-detected coverage at {}; pass --coverage explicitly for deterministic CI scores",
3921 auto_path.display()
3922 );
3923 }
3924 Ok(scoring::load_istanbul_coverage(
3925 &auto_path,
3926 opts.coverage_inputs.coverage_root,
3927 Some(&config.root),
3928 )
3929 .ok())
3930}
3931
3932fn prepare_shared_analysis_output(
3933 opts: &HealthOptions<'_>,
3934 config: &ResolvedConfig,
3935 modules: &[crate::extract::ModuleInfo],
3936 pre_computed: Option<crate::DeadCodeAnalysisArtifacts>,
3937 needed: bool,
3938) -> Result<Option<crate::DeadCodeAnalysisArtifacts>, ExitCode> {
3939 if !needed {
3940 return Ok(None);
3941 }
3942 if let Some(pre) = pre_computed {
3943 return Ok(Some(pre));
3944 }
3945 crate::analyze_with_parse_result(config, modules)
3946 .map(Some)
3947 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))
3948}
3949
3950#[derive(Clone, Copy)]
3951struct RuntimeCoverageAnalysisScope<'a> {
3952 opts: &'a HealthOptions<'a>,
3953 config: &'a ResolvedConfig,
3954 modules: &'a [crate::extract::ModuleInfo],
3955 shared_analysis_output: Option<&'a crate::DeadCodeAnalysisArtifacts>,
3956 istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
3957 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
3958 ignore_set: &'a globset::GlobSet,
3959 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3960 ws_roots: Option<&'a [std::path::PathBuf]>,
3961}
3962
3963fn analyze_runtime_coverage(
3964 input: RuntimeCoverageAnalysisScope<'_>,
3965 seams: &HealthSeams<'_>,
3966) -> Result<Option<fallow_output::RuntimeCoverageReport>, ExitCode> {
3967 let Some(production_options) = input.opts.runtime_coverage.as_ref() else {
3968 return Ok(None);
3969 };
3970 let Some(analysis_output) = input.shared_analysis_output else {
3971 return Err(emit_error(
3972 "runtime coverage requires analysis output",
3973 2,
3974 input.opts.output,
3975 ));
3976 };
3977 (seams.runtime_coverage_analyzer)(
3978 production_options,
3979 RuntimeCoverageSeamInput {
3980 root: &input.config.root,
3981 modules: input.modules,
3982 analysis_output,
3983 istanbul_coverage: input.istanbul_coverage,
3984 file_paths: input.file_paths,
3985 ignore_set: input.ignore_set,
3986 changed_files: input.changed_files,
3987 ws_roots: input.ws_roots,
3988 top: input.opts.top,
3989 codeowners_path: input.config.codeowners.as_deref(),
3990 quiet: input.opts.quiet,
3991 output: input.opts.output,
3992 },
3993 )
3994 .map(Some)
3995}
3996
3997struct HealthAnalysisData {
3998 runtime_coverage: Option<fallow_output::RuntimeCoverageReport>,
3999 score_output: Option<scoring::FileScoreOutput>,
4000 files_scored: Option<usize>,
4001 average_maintainability: Option<f64>,
4002 framework_health_facts: Option<FrameworkHealthFacts>,
4003 file_scores_ms: f64,
4004 git_churn_ms: f64,
4005 git_churn_cache_hit: bool,
4006 churn_fetch: Option<hotspots::ChurnFetchResult>,
4007}
4008
4009#[derive(Clone, Copy, Default)]
4010struct FrameworkHealthFacts {
4011 unused_load_data_keys_global_abstain: bool,
4012}
4013
4014fn build_framework_health_diagnostics(
4015 config: &ResolvedConfig,
4016 facts: Option<FrameworkHealthFacts>,
4017) -> Option<fallow_output::FrameworkHealthDiagnostics> {
4018 let facts = facts?;
4019 let detected_frameworks = detect_frameworks(config);
4020 if detected_frameworks.is_empty() {
4021 return None;
4022 }
4023
4024 let mut detectors = Vec::new();
4025 for framework in &detected_frameworks {
4026 add_framework_detectors(&mut detectors, framework, &config.rules, facts);
4027 }
4028
4029 if detectors.is_empty() {
4030 return None;
4031 }
4032
4033 Some(fallow_output::FrameworkHealthDiagnostics {
4034 detected_frameworks,
4035 detectors,
4036 })
4037}
4038
4039fn detect_frameworks(config: &ResolvedConfig) -> Vec<String> {
4040 let mut deps = rustc_hash::FxHashSet::default();
4041 if let Ok(pkg) = PackageJson::load(&config.root.join("package.json")) {
4042 deps.extend(pkg.all_dependency_names());
4043 }
4044 for workspace in fallow_config::discover_workspaces(&config.root) {
4045 if let Ok(pkg) = PackageJson::load(&workspace.root.join("package.json")) {
4046 deps.extend(pkg.all_dependency_names());
4047 }
4048 }
4049
4050 let mut frameworks = Vec::new();
4051 if deps.contains("react") || deps.contains("preact") || deps.contains("next") {
4052 frameworks.push("react".to_string());
4053 }
4054 if deps.contains("next") {
4055 frameworks.push("next".to_string());
4056 }
4057 if deps.contains("vue") || deps.contains("@vue/runtime-core") {
4058 frameworks.push("vue".to_string());
4059 }
4060 if deps.contains("nuxt") {
4061 frameworks.push("nuxt".to_string());
4062 }
4063 if deps.contains("svelte") || deps.contains("@sveltejs/kit") {
4064 frameworks.push("svelte".to_string());
4065 }
4066 if deps.contains("@sveltejs/kit") {
4067 frameworks.push("sveltekit".to_string());
4068 }
4069 if deps.contains("@angular/core") {
4070 frameworks.push("angular".to_string());
4071 }
4072 frameworks.sort_unstable();
4073 frameworks.dedup();
4074 frameworks
4075}
4076
4077fn add_framework_detectors(
4078 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4079 framework: &str,
4080 rules: &fallow_config::RulesConfig,
4081 facts: FrameworkHealthFacts,
4082) {
4083 match framework {
4084 "angular" => add_angular_detectors(detectors, framework, rules),
4085 "next" => add_next_detectors(detectors, framework, rules),
4086 "nuxt" => add_nuxt_detectors(detectors, framework, rules),
4087 "vue" => add_vue_detectors(detectors, framework, rules),
4088 "react" => add_react_detectors(detectors, framework, rules),
4089 "svelte" => add_svelte_detectors(detectors, framework, rules),
4090 "sveltekit" => add_sveltekit_detectors(detectors, framework, rules, facts),
4091 _ => {}
4092 }
4093}
4094
4095fn add_angular_detectors(
4096 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4097 framework: &str,
4098 rules: &fallow_config::RulesConfig,
4099) {
4100 add_detector(
4101 detectors,
4102 framework,
4103 "unrendered-component",
4104 rules.unrendered_components,
4105 );
4106 add_detector(
4107 detectors,
4108 framework,
4109 "unused-component-input",
4110 rules.unused_component_inputs,
4111 );
4112 add_detector(
4113 detectors,
4114 framework,
4115 "unused-component-output",
4116 rules.unused_component_outputs,
4117 );
4118 add_detector(
4119 detectors,
4120 framework,
4121 "unprovided-inject",
4122 rules.unprovided_injects,
4123 );
4124}
4125
4126fn add_next_detectors(
4127 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4128 framework: &str,
4129 rules: &fallow_config::RulesConfig,
4130) {
4131 add_detector(
4132 detectors,
4133 framework,
4134 "invalid-client-export",
4135 rules.invalid_client_export,
4136 );
4137 add_detector(
4138 detectors,
4139 framework,
4140 "mixed-client-server-barrel",
4141 rules.mixed_client_server_barrel,
4142 );
4143 add_detector(
4144 detectors,
4145 framework,
4146 "misplaced-directive",
4147 rules.misplaced_directive,
4148 );
4149 add_detector(
4150 detectors,
4151 framework,
4152 "route-collision",
4153 rules.route_collision,
4154 );
4155 add_detector(
4156 detectors,
4157 framework,
4158 "dynamic-segment-name-conflict",
4159 rules.dynamic_segment_name_conflict,
4160 );
4161 add_detector(
4162 detectors,
4163 framework,
4164 "unused-server-action",
4165 rules.unused_server_actions,
4166 );
4167}
4168
4169fn add_nuxt_detectors(
4170 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4171 framework: &str,
4172 rules: &fallow_config::RulesConfig,
4173) {
4174 add_detector(
4175 detectors,
4176 framework,
4177 "unrendered-component",
4178 rules.unrendered_components,
4179 );
4180 add_detector(
4181 detectors,
4182 framework,
4183 "unused-component-prop",
4184 rules.unused_component_props,
4185 );
4186 add_detector(
4187 detectors,
4188 framework,
4189 "unused-component-emit",
4190 rules.unused_component_emits,
4191 );
4192 add_not_checked_detector(
4193 detectors,
4194 framework,
4195 "unprovided-inject",
4196 "requires_vue_runtime_dependency",
4197 );
4198}
4199
4200fn add_vue_detectors(
4201 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4202 framework: &str,
4203 rules: &fallow_config::RulesConfig,
4204) {
4205 add_detector(
4206 detectors,
4207 framework,
4208 "unrendered-component",
4209 rules.unrendered_components,
4210 );
4211 add_detector(
4212 detectors,
4213 framework,
4214 "unused-component-prop",
4215 rules.unused_component_props,
4216 );
4217 add_detector(
4218 detectors,
4219 framework,
4220 "unused-component-emit",
4221 rules.unused_component_emits,
4222 );
4223 add_detector(
4224 detectors,
4225 framework,
4226 "unprovided-inject",
4227 rules.unprovided_injects,
4228 );
4229}
4230
4231fn add_react_detectors(
4232 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4233 framework: &str,
4234 rules: &fallow_config::RulesConfig,
4235) {
4236 add_detector(
4237 detectors,
4238 framework,
4239 "unused-component-prop",
4240 rules.unused_component_props,
4241 );
4242 add_detector(detectors, framework, "prop-drilling", rules.prop_drilling);
4243 add_detector(detectors, framework, "thin-wrapper", rules.thin_wrapper);
4244 add_detector(
4245 detectors,
4246 framework,
4247 "duplicate-prop-shape",
4248 rules.duplicate_prop_shape,
4249 );
4250}
4251
4252fn add_svelte_detectors(
4253 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4254 framework: &str,
4255 rules: &fallow_config::RulesConfig,
4256) {
4257 add_detector(
4258 detectors,
4259 framework,
4260 "unrendered-component",
4261 rules.unrendered_components,
4262 );
4263 add_detector(
4264 detectors,
4265 framework,
4266 "unused-component-prop",
4267 rules.unused_component_props,
4268 );
4269 add_detector(
4270 detectors,
4271 framework,
4272 "unused-svelte-event",
4273 rules.unused_svelte_events,
4274 );
4275 add_detector(
4276 detectors,
4277 framework,
4278 "unprovided-inject",
4279 rules.unprovided_injects,
4280 );
4281}
4282
4283fn add_sveltekit_detectors(
4284 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4285 framework: &str,
4286 rules: &fallow_config::RulesConfig,
4287 facts: FrameworkHealthFacts,
4288) {
4289 if facts.unused_load_data_keys_global_abstain && rules.unused_load_data_keys != Severity::Off {
4290 detectors.push(fallow_output::FrameworkHealthDetector {
4291 id: "unused-load-data-key".to_string(),
4292 framework: framework.to_string(),
4293 status: fallow_output::FrameworkHealthDetectorStatus::Abstained,
4294 reason: Some("unused_load_data_keys_global_abstain".to_string()),
4295 });
4296 } else {
4297 add_detector(
4298 detectors,
4299 framework,
4300 "unused-load-data-key",
4301 rules.unused_load_data_keys,
4302 );
4303 }
4304}
4305
4306fn add_detector(
4307 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4308 framework: &str,
4309 id: &str,
4310 severity: Severity,
4311) {
4312 let (status, reason) = if severity == Severity::Off {
4313 (
4314 fallow_output::FrameworkHealthDetectorStatus::DisabledByConfig,
4315 Some("disabled_by_config".to_string()),
4316 )
4317 } else {
4318 (fallow_output::FrameworkHealthDetectorStatus::Active, None)
4319 };
4320 detectors.push(fallow_output::FrameworkHealthDetector {
4321 id: id.to_string(),
4322 framework: framework.to_string(),
4323 status,
4324 reason,
4325 });
4326}
4327
4328fn add_not_checked_detector(
4329 detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4330 framework: &str,
4331 id: &str,
4332 reason: &str,
4333) {
4334 detectors.push(fallow_output::FrameworkHealthDetector {
4335 id: id.to_string(),
4336 framework: framework.to_string(),
4337 status: fallow_output::FrameworkHealthDetectorStatus::NotChecked,
4338 reason: Some(reason.to_string()),
4339 });
4340}
4341
4342struct HealthRuntimeSectionsInput<'a> {
4343 config: &'a ResolvedConfig,
4344 files: &'a [fallow_types::discover::DiscoveredFile],
4345 modules: &'a [crate::extract::ModuleInfo],
4346 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
4347 ignore_set: &'a globset::GlobSet,
4348 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4349 ws_roots: Option<&'a [std::path::PathBuf]>,
4350 diff_index: Option<&'a fallow_output::DiffIndex>,
4351 loaded_baseline: Option<&'a HealthBaselineData>,
4352 findings: &'a [ComplexityViolation],
4353 analysis_data: HealthAnalysisData,
4354 has_istanbul_coverage: bool,
4355 needs_file_scores: bool,
4356}
4357
4358struct HealthRuntimeSections {
4359 analysis_data: HealthAnalysisData,
4360 derived_sections: HealthDerivedSections,
4361 vital_data: HealthVitalData,
4362}
4363
4364fn prepare_health_runtime_sections(
4365 opts: &HealthOptions<'_>,
4366 mut input: HealthRuntimeSectionsInput<'_>,
4367) -> Result<HealthRuntimeSections, ExitCode> {
4368 let file_scores_slice = health_file_scores_slice(input.analysis_data.score_output.as_ref());
4369 let derived_sections = prepare_health_derived_sections(
4370 opts,
4371 HealthDerivedSectionInput {
4372 config: input.config,
4373 files: input.files,
4374 ignore_set: input.ignore_set,
4375 changed_files: input.changed_files,
4376 ws_roots: input.ws_roots,
4377 file_scores: file_scores_slice,
4378 churn_fetch: input.analysis_data.churn_fetch.take(),
4379 diff_index: input.diff_index,
4380 score_output: input.analysis_data.score_output.as_ref(),
4381 loaded_baseline: input.loaded_baseline,
4382 },
4383 );
4384
4385 finalize_health_runtime_outputs(
4386 opts,
4387 HealthRuntimeFinalizeInput {
4388 config: input.config,
4389 runtime_coverage: &mut input.analysis_data.runtime_coverage,
4390 findings: input.findings,
4391 targets: &derived_sections.targets,
4392 loaded_baseline: input.loaded_baseline,
4393 changed_files: input.changed_files,
4394 diff_index: input.diff_index,
4395 },
4396 )?;
4397
4398 let vital_data = prepare_health_vital_data_from_sections(
4399 opts,
4400 &input,
4401 &derived_sections,
4402 file_scores_slice,
4403 )?;
4404
4405 Ok(HealthRuntimeSections {
4406 analysis_data: input.analysis_data,
4407 derived_sections,
4408 vital_data,
4409 })
4410}
4411
4412fn prepare_health_vital_data_from_sections(
4413 opts: &HealthOptions<'_>,
4414 input: &HealthRuntimeSectionsInput<'_>,
4415 derived_sections: &HealthDerivedSections,
4416 file_scores_slice: &[FileHealthScore],
4417) -> Result<HealthVitalData, ExitCode> {
4418 prepare_health_vital_data(&HealthVitalDataInput {
4419 opts,
4420 modules: input.modules,
4421 file_paths: input.file_paths,
4422 score_output: input.analysis_data.score_output.as_ref(),
4423 file_scores_slice,
4424 hotspots: &derived_sections.hotspots,
4425 dupes_report: derived_sections.dupes_report.as_ref(),
4426 candidate_paths: &derived_sections.candidate_paths,
4427 total_files: input.files.len(),
4428 config: input.config,
4429 ignore_set: input.ignore_set,
4430 changed_files: input.changed_files,
4431 ws_roots: input.ws_roots,
4432 diff_index: input.diff_index,
4433 hotspot_summary: derived_sections.hotspot_summary.as_ref(),
4434 has_istanbul_coverage: input.has_istanbul_coverage,
4435 needs_file_scores: input.needs_file_scores,
4436 })
4437}
4438
4439struct HealthAnalysisDataInput<'a> {
4440 opts: &'a HealthOptions<'a>,
4441 config: &'a ResolvedConfig,
4442 modules: &'a [crate::extract::ModuleInfo],
4443 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
4444 ignore_set: &'a globset::GlobSet,
4445 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4446 ws_roots: Option<&'a [std::path::PathBuf]>,
4447 istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
4448 pre_computed_analysis: Option<crate::DeadCodeAnalysisArtifacts>,
4449 needs_file_scores: bool,
4450 seams: &'a HealthSeams<'a>,
4451}
4452
4453fn prepare_health_analysis_data(
4454 input: HealthAnalysisDataInput<'_>,
4455) -> Result<HealthAnalysisData, ExitCode> {
4456 let mut input = input;
4457 let needs_analysis_output = input.needs_file_scores || input.opts.runtime_coverage.is_some();
4458 let seams = input.seams;
4459 let mut shared_analysis =
4460 prepare_shared_health_analysis(&mut input, needs_analysis_output, seams)?;
4461
4462 let runtime_coverage = analyze_runtime_coverage(
4463 RuntimeCoverageAnalysisScope {
4464 opts: input.opts,
4465 config: input.config,
4466 modules: input.modules,
4467 shared_analysis_output: shared_analysis.output.as_ref(),
4468 istanbul_coverage: input.istanbul_coverage,
4469 file_paths: input.file_paths,
4470 ignore_set: input.ignore_set,
4471 changed_files: input.changed_files,
4472 ws_roots: input.ws_roots,
4473 },
4474 seams,
4475 )?;
4476
4477 let precomputed_for_scores = shared_analysis.take_for_file_scores(input.needs_file_scores);
4478
4479 let (file_score_result, file_scores_ms, churn_fetch) = compute_file_scores_and_churn(
4480 FileScoresAndChurnInput {
4481 opts: input.opts,
4482 config: input.config,
4483 modules: input.modules,
4484 file_paths: input.file_paths,
4485 changed_files: input.changed_files,
4486 ws_roots: input.ws_roots,
4487 ignore_set: input.ignore_set,
4488 istanbul_coverage: input.istanbul_coverage,
4489 needs_file_scores: input.needs_file_scores,
4490 },
4491 precomputed_for_scores,
4492 )?;
4493 let (git_churn_ms, git_churn_cache_hit) = churn_fetch
4494 .as_ref()
4495 .map_or((0.0, false), |cf| (cf.git_log_ms, cf.cache_hit));
4496 let (score_output, files_scored, average_maintainability) = file_score_result;
4497
4498 print_slow_churn_note(input.opts, churn_fetch.as_ref());
4499
4500 Ok(HealthAnalysisData {
4501 runtime_coverage,
4502 score_output,
4503 files_scored,
4504 average_maintainability,
4505 framework_health_facts: shared_analysis.framework_health_facts,
4506 file_scores_ms,
4507 git_churn_ms,
4508 git_churn_cache_hit,
4509 churn_fetch,
4510 })
4511}
4512
4513struct PreparedSharedHealthAnalysis {
4514 output: Option<crate::DeadCodeAnalysisArtifacts>,
4515 framework_health_facts: Option<FrameworkHealthFacts>,
4516}
4517
4518impl PreparedSharedHealthAnalysis {
4519 fn take_for_file_scores(
4520 &mut self,
4521 needs_file_scores: bool,
4522 ) -> Option<crate::DeadCodeAnalysisArtifacts> {
4523 if needs_file_scores {
4524 self.output.take()
4525 } else {
4526 None
4527 }
4528 }
4529}
4530
4531fn prepare_shared_health_analysis(
4532 input: &mut HealthAnalysisDataInput<'_>,
4533 needs_analysis_output: bool,
4534 seams: &HealthSeams<'_>,
4535) -> Result<PreparedSharedHealthAnalysis, ExitCode> {
4536 let output = prepare_shared_analysis_output(
4537 input.opts,
4538 input.config,
4539 input.modules,
4540 input.pre_computed_analysis.take(),
4541 needs_analysis_output,
4542 )?;
4543 let framework_health_facts = output.as_ref().map(|output| FrameworkHealthFacts {
4544 unused_load_data_keys_global_abstain: output.results.unused_load_data_keys_global_abstain,
4545 });
4546 if let Some(graph) = output.as_ref().and_then(|output| output.graph.as_ref()) {
4547 (seams.note_graph_structure)(graph.module_count(), graph.edge_count());
4548 }
4549
4550 Ok(PreparedSharedHealthAnalysis {
4551 output,
4552 framework_health_facts,
4553 })
4554}
4555
4556type FileScoresAndChurn = (FileScoreResult, f64, Option<hotspots::ChurnFetchResult>);
4557
4558#[derive(Clone, Copy)]
4559struct FileScoresAndChurnInput<'a> {
4560 opts: &'a HealthOptions<'a>,
4561 config: &'a ResolvedConfig,
4562 modules: &'a [crate::extract::ModuleInfo],
4563 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
4564 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4565 ws_roots: Option<&'a [std::path::PathBuf]>,
4566 ignore_set: &'a globset::GlobSet,
4567 istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
4568 needs_file_scores: bool,
4569}
4570
4571fn compute_file_scores_and_churn(
4572 input: FileScoresAndChurnInput<'_>,
4573 precomputed_for_scores: Option<crate::DeadCodeAnalysisArtifacts>,
4574) -> Result<FileScoresAndChurn, ExitCode> {
4575 let needs_churn = input.opts.hotspots || input.opts.targets;
4576 if input.needs_file_scores && needs_churn {
4577 return std::thread::scope(|s| {
4578 let churn_handle =
4579 s.spawn(|| hotspots::fetch_churn_data(input.opts, &input.config.cache_dir));
4580 let t = Instant::now();
4581 let score_result = compute_filtered_file_scores(FileScoreInput {
4582 config: input.config,
4583 modules: input.modules,
4584 file_paths: input.file_paths,
4585 changed_files: input.changed_files,
4586 ws_roots: input.ws_roots,
4587 ignore_set: input.ignore_set,
4588 output: input.opts.output,
4589 istanbul_coverage: input.istanbul_coverage,
4590 pre_computed: precomputed_for_scores,
4591 })?;
4592 let fs_ms = t.elapsed().as_secs_f64() * 1000.0;
4593 let churn = churn_handle
4594 .join()
4595 .map_err(|_| emit_error("churn thread panicked", 2, input.opts.output))?;
4596 Ok((score_result, fs_ms, churn))
4597 });
4598 }
4599
4600 let t = Instant::now();
4601 let score_result = if input.needs_file_scores {
4602 compute_filtered_file_scores(FileScoreInput {
4603 config: input.config,
4604 modules: input.modules,
4605 file_paths: input.file_paths,
4606 changed_files: input.changed_files,
4607 ws_roots: input.ws_roots,
4608 ignore_set: input.ignore_set,
4609 output: input.opts.output,
4610 istanbul_coverage: input.istanbul_coverage,
4611 pre_computed: precomputed_for_scores,
4612 })?
4613 } else {
4614 (None, None, None)
4615 };
4616 let fs_ms = t.elapsed().as_secs_f64() * 1000.0;
4617 let churn = if needs_churn {
4618 hotspots::fetch_churn_data(input.opts, &input.config.cache_dir)
4619 } else {
4620 None
4621 };
4622 Ok((score_result, fs_ms, churn))
4623}
4624
4625fn print_slow_churn_note(
4626 opts: &HealthOptions<'_>,
4627 churn_fetch: Option<&hotspots::ChurnFetchResult>,
4628) {
4629 if let Some(cf) = churn_fetch
4630 && !cf.cache_hit
4631 && !opts.no_cache
4632 && !opts.quiet
4633 && cf.git_log_ms > 500.0
4634 {
4635 eprintln!(
4636 "{}",
4637 format!(
4638 " note: git churn analysis took {:.1}s (cached for next run at same HEAD)",
4639 cf.git_log_ms / 1000.0
4640 )
4641 .dimmed()
4642 );
4643 }
4644}
4645
4646fn count_finding_severities(findings: &[ComplexityViolation]) -> (usize, usize, usize) {
4647 let (mut critical, mut high, mut moderate) = (0usize, 0usize, 0usize);
4648 for finding in findings {
4649 match finding.severity {
4650 FindingSeverity::Critical => critical += 1,
4651 FindingSeverity::High => high += 1,
4652 FindingSeverity::Moderate => moderate += 1,
4653 }
4654 }
4655 (critical, high, moderate)
4656}
4657
4658fn apply_health_baseline_and_top(
4659 opts: &HealthOptions<'_>,
4660 config: &ResolvedConfig,
4661 findings: &mut Vec<ComplexityViolation>,
4662) -> Result<Option<HealthBaselineData>, ExitCode> {
4663 let loaded_baseline = if let Some(load_path) = opts.baseline {
4664 Some(load_health_baseline(
4665 load_path,
4666 findings,
4667 &config.root,
4668 opts.quiet,
4669 opts.output,
4670 )?)
4671 } else {
4672 None
4673 };
4674 if let Some(top) = opts.top {
4675 findings.truncate(top);
4676 }
4677 Ok(loaded_baseline)
4678}
4679
4680struct FilteredHotspotInput<'a> {
4681 opts: &'a HealthOptions<'a>,
4682 config: &'a ResolvedConfig,
4683 file_scores_slice: &'a [FileHealthScore],
4684 ignore_set: &'a globset::GlobSet,
4685 ws_roots: Option<&'a [std::path::PathBuf]>,
4686 churn_fetch: Option<hotspots::ChurnFetchResult>,
4687 diff_index: Option<&'a fallow_output::DiffIndex>,
4688}
4689
4690fn compute_filtered_hotspots(
4691 input: FilteredHotspotInput<'_>,
4692) -> (Vec<HotspotEntry>, Option<HotspotSummary>, f64) {
4693 let t = Instant::now();
4694 let (mut hotspots, hotspot_summary) = if let Some(churn_data) = input.churn_fetch {
4695 compute_hotspots(
4696 input.opts,
4697 input.config,
4698 input.file_scores_slice,
4699 input.ignore_set,
4700 input.ws_roots,
4701 churn_data,
4702 )
4703 } else {
4704 (Vec::new(), None)
4705 };
4706 if let Some(diff_index) = input.diff_index {
4707 filter_hotspots_by_diff(&mut hotspots, diff_index, &input.config.root);
4708 }
4709 (
4710 hotspots,
4711 hotspot_summary,
4712 t.elapsed().as_secs_f64() * 1000.0,
4713 )
4714}
4715
4716#[derive(Clone, Copy)]
4717struct FilteredTargetInput<'a> {
4718 opts: &'a HealthOptions<'a>,
4719 score_output: Option<&'a scoring::FileScoreOutput>,
4720 file_scores_slice: &'a [FileHealthScore],
4721 hotspots: &'a [HotspotEntry],
4722 loaded_baseline: Option<&'a HealthBaselineData>,
4723 config: &'a ResolvedConfig,
4724 diff_index: Option<&'a fallow_output::DiffIndex>,
4725 dupes_report: Option<&'a crate::duplicates::DuplicationReport>,
4726}
4727
4728fn compute_filtered_targets(
4729 input: FilteredTargetInput<'_>,
4730) -> (Vec<RefactoringTarget>, Option<TargetThresholds>, f64) {
4731 let t = Instant::now();
4732 let (mut targets, target_thresholds) = compute_targets(&input);
4733 if let Some(diff_index) = input.diff_index {
4734 filter_refactoring_targets_by_diff(&mut targets, diff_index, &input.config.root);
4735 }
4736 (
4737 targets,
4738 target_thresholds,
4739 t.elapsed().as_secs_f64() * 1000.0,
4740 )
4741}
4742
4743fn filter_runtime_coverage_report(
4744 opts: &HealthOptions<'_>,
4745 config: &ResolvedConfig,
4746 report: Option<&mut fallow_output::RuntimeCoverageReport>,
4747 loaded_baseline: Option<&HealthBaselineData>,
4748 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
4749 diff_index: Option<&fallow_output::DiffIndex>,
4750) {
4751 if let Some(report) = report {
4752 let ctx = RuntimeCoverageFilterContext::new(&config.root)
4753 .with_baseline(loaded_baseline)
4754 .with_top(opts.top)
4755 .with_changed_files(changed_files)
4756 .with_diff_index(diff_index);
4757 apply_runtime_coverage_filters(report, &ctx);
4758 }
4759}
4760
4761fn save_health_baseline_if_requested(
4762 opts: &HealthOptions<'_>,
4763 config: &ResolvedConfig,
4764 findings: &[ComplexityViolation],
4765 runtime_coverage: Option<&fallow_output::RuntimeCoverageReport>,
4766 targets: &[RefactoringTarget],
4767) -> Result<(), ExitCode> {
4768 if let Some(save_path) = opts.save_baseline {
4769 save_health_baseline(&HealthBaselineSaveInput {
4770 save_path,
4771 findings,
4772 runtime_coverage_findings: runtime_coverage
4773 .map_or(&[], |report| report.findings.as_slice()),
4774 targets,
4775 config_root: &config.root,
4776 quiet: opts.quiet,
4777 output: opts.output,
4778 })?;
4779 }
4780 Ok(())
4781}
4782
4783struct HealthRuntimeFinalizeInput<'a> {
4784 config: &'a ResolvedConfig,
4785 runtime_coverage: &'a mut Option<fallow_output::RuntimeCoverageReport>,
4786 findings: &'a [ComplexityViolation],
4787 targets: &'a [RefactoringTarget],
4788 loaded_baseline: Option<&'a HealthBaselineData>,
4789 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4790 diff_index: Option<&'a fallow_output::DiffIndex>,
4791}
4792
4793fn finalize_health_runtime_outputs(
4794 opts: &HealthOptions<'_>,
4795 input: HealthRuntimeFinalizeInput<'_>,
4796) -> Result<(), ExitCode> {
4797 let HealthRuntimeFinalizeInput {
4798 config,
4799 runtime_coverage,
4800 findings,
4801 targets,
4802 loaded_baseline,
4803 changed_files,
4804 diff_index,
4805 } = input;
4806
4807 filter_runtime_coverage_report(
4808 opts,
4809 config,
4810 runtime_coverage.as_mut(),
4811 loaded_baseline,
4812 changed_files,
4813 diff_index,
4814 );
4815 save_health_baseline_if_requested(opts, config, findings, runtime_coverage.as_ref(), targets)
4816}
4817
4818fn prepare_health_duplication_data(
4819 opts: &HealthOptions<'_>,
4820 config: &ResolvedConfig,
4821 files: &[fallow_types::discover::DiscoveredFile],
4822 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
4823 ws_roots: Option<&[std::path::PathBuf]>,
4824 ignore_set: &globset::GlobSet,
4825) -> (
4826 rustc_hash::FxHashSet<std::path::PathBuf>,
4827 Option<crate::duplicates::DuplicationReport>,
4828 f64,
4829) {
4830 let candidate_paths =
4831 collect_candidate_paths(files, config, changed_files, ws_roots, ignore_set);
4832 let (dupes_report, duplication_ms) =
4833 compute_health_duplication_report(opts, config, files, &candidate_paths);
4834 (candidate_paths, dupes_report, duplication_ms)
4835}
4836
4837fn compute_health_duplication_report(
4838 opts: &HealthOptions<'_>,
4839 config: &ResolvedConfig,
4840 files: &[fallow_types::discover::DiscoveredFile],
4841 candidate_paths: &rustc_hash::FxHashSet<std::path::PathBuf>,
4842) -> (Option<crate::duplicates::DuplicationReport>, f64) {
4843 let t = Instant::now();
4844 let dupes_report = if opts.score || opts.targets {
4845 let scoped_files = filter_files_to_paths(files, candidate_paths);
4846 Some(if opts.no_cache {
4847 crate::duplicates::find_duplicates(&config.root, &scoped_files, &config.duplicates)
4848 } else {
4849 crate::duplicates::find_duplicates_cached(
4850 &config.root,
4851 &scoped_files,
4852 &config.duplicates,
4853 &config.cache_dir,
4854 )
4855 })
4856 } else {
4857 None
4858 };
4859 (dupes_report, t.elapsed().as_secs_f64() * 1000.0)
4860}
4861
4862struct HealthVitalData {
4863 vital_signs: fallow_output::VitalSigns,
4864 health_score: Option<HealthScore>,
4865 health_trend: Option<fallow_output::HealthTrend>,
4866 large_functions: Vec<fallow_output::LargeFunctionEntry>,
4867}
4868
4869struct HealthVitalDataInput<'a> {
4870 opts: &'a HealthOptions<'a>,
4871 modules: &'a [crate::extract::ModuleInfo],
4872 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
4873 score_output: Option<&'a scoring::FileScoreOutput>,
4874 file_scores_slice: &'a [FileHealthScore],
4875 hotspots: &'a [HotspotEntry],
4876 dupes_report: Option<&'a crate::duplicates::DuplicationReport>,
4877 candidate_paths: &'a rustc_hash::FxHashSet<std::path::PathBuf>,
4878 total_files: usize,
4879 config: &'a ResolvedConfig,
4880 ignore_set: &'a globset::GlobSet,
4881 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4882 ws_roots: Option<&'a [std::path::PathBuf]>,
4883 diff_index: Option<&'a fallow_output::DiffIndex>,
4884 hotspot_summary: Option<&'a HotspotSummary>,
4885 has_istanbul_coverage: bool,
4886 needs_file_scores: bool,
4887}
4888
4889fn apply_prop_drilling_metrics(
4894 vital_signs: &mut fallow_output::VitalSigns,
4895 score_output: &scoring::FileScoreOutput,
4896) {
4897 if score_output.prop_drilling_chains.is_empty() {
4898 return;
4899 }
4900 vital_signs.prop_drilling_chain_count =
4901 u32::try_from(score_output.prop_drilling_chains.len()).ok();
4902 vital_signs.prop_drilling_max_depth = score_output
4903 .prop_drilling_chains
4904 .iter()
4905 .map(|c| c.chain.depth)
4906 .max();
4907}
4908
4909fn apply_render_fan_in_metrics(
4914 vital_signs: &mut fallow_output::VitalSigns,
4915 score_output: &scoring::FileScoreOutput,
4916 config: &ResolvedConfig,
4917) {
4918 let Some(metric) = score_output.render_fan_in.as_ref() else {
4919 return;
4920 };
4921 vital_signs.p95_render_fan_in = metric.p95_distinct_parents;
4922 vital_signs.render_fan_in_high_pct = metric.high_pct;
4923 vital_signs.max_render_fan_in = metric.max_distinct_parents;
4927
4928 const MAX_TOP_RENDER_FAN_IN: usize = 20;
4937 let mut top: Vec<&fallow_types::results::RenderFanInComponent> = metric
4938 .per_component
4939 .iter()
4940 .filter(|c| c.distinct_parents > 0)
4941 .collect();
4942 top.sort_by(|a, b| {
4943 b.distinct_parents
4944 .cmp(&a.distinct_parents)
4945 .then_with(|| b.render_sites.cmp(&a.render_sites))
4946 .then_with(|| a.file.cmp(&b.file))
4947 .then_with(|| a.component.cmp(&b.component))
4948 });
4949 vital_signs.top_render_fan_in = top
4950 .into_iter()
4951 .take(MAX_TOP_RENDER_FAN_IN)
4952 .map(|c| fallow_output::RenderFanInTopComponent {
4953 component: c.component.clone(),
4954 path: c
4955 .file
4956 .strip_prefix(&config.root)
4957 .unwrap_or(&c.file)
4958 .to_path_buf(),
4959 render_sites: c.render_sites,
4960 distinct_parents: c.distinct_parents,
4961 })
4962 .collect();
4963}
4964
4965fn compute_scoped_vital_signs(
4968 input: &HealthVitalDataInput<'_>,
4969 total_files_scoped: usize,
4970 project_subset: &SubsetFilter<'_>,
4971) -> (fallow_output::VitalSigns, fallow_output::VitalSignsCounts) {
4972 let vital_signs_input = VitalSignsAndCountsInput {
4973 score_output: input.score_output,
4974 modules: input.modules,
4975 file_paths: input.file_paths,
4976 needs_file_scores: input.needs_file_scores,
4977 file_scores_slice: input.file_scores_slice,
4978 needs_hotspots: input.opts.hotspots || input.opts.targets,
4979 hotspots: input.hotspots,
4980 total_files: total_files_scoped,
4981 subset: project_subset,
4982 };
4983 let (mut vital_signs, counts) = compute_vital_signs_and_counts(&vital_signs_input);
4984
4985 if let Some(score_output) = input.score_output {
4986 apply_prop_drilling_metrics(&mut vital_signs, score_output);
4987 apply_render_fan_in_metrics(&mut vital_signs, score_output, input.config);
4988 }
4989 (vital_signs, counts)
4990}
4991
4992fn maybe_save_health_snapshot(
4994 input: &HealthVitalDataInput<'_>,
4995 vital_signs: &fallow_output::VitalSigns,
4996 counts: &fallow_output::VitalSignsCounts,
4997 health_score: Option<&HealthScore>,
4998) -> Result<(), ExitCode> {
4999 if let Some(ref snapshot_path) = input.opts.save_snapshot {
5000 save_snapshot(SnapshotInput {
5001 opts: input.opts,
5002 snapshot_path,
5003 vital_signs,
5004 counts,
5005 hotspot_summary: input.hotspot_summary,
5006 health_score,
5007 coverage_model: Some(active_health_coverage_model(input.has_istanbul_coverage)),
5008 })?;
5009 }
5010 Ok(())
5011}
5012
5013fn prepare_health_vital_data(
5014 input: &HealthVitalDataInput<'_>,
5015) -> Result<HealthVitalData, ExitCode> {
5016 let project_subset = if input.candidate_paths.len() == input.total_files {
5017 SubsetFilter::Full
5018 } else {
5019 SubsetFilter::Paths(input.candidate_paths)
5020 };
5021 let total_files_scoped = input.candidate_paths.len();
5022 let (mut vital_signs, mut counts) =
5023 compute_scoped_vital_signs(input, total_files_scoped, &project_subset);
5024
5025 let health_score = compute_health_score_metrics(
5026 input.opts,
5027 input.dupes_report,
5028 &mut vital_signs,
5029 &mut counts,
5030 total_files_scoped,
5031 );
5032 let large_functions = collect_filtered_large_functions(FilteredLargeFunctionInput {
5033 vital_signs: &vital_signs,
5034 modules: input.modules,
5035 file_paths: input.file_paths,
5036 config: input.config,
5037 ignore_set: input.ignore_set,
5038 changed_files: input.changed_files,
5039 ws_roots: input.ws_roots,
5040 diff_index: input.diff_index,
5041 });
5042 maybe_save_health_snapshot(input, &vital_signs, &counts, health_score.as_ref())?;
5043 let health_trend =
5044 compute_health_trend(input.opts, &vital_signs, &counts, health_score.as_ref());
5045
5046 Ok(HealthVitalData {
5047 vital_signs,
5048 health_score,
5049 health_trend,
5050 large_functions,
5051 })
5052}
5053
5054fn compute_health_score_metrics(
5055 opts: &HealthOptions<'_>,
5056 dupes_report: Option<&crate::duplicates::DuplicationReport>,
5057 vital_signs: &mut fallow_output::VitalSigns,
5058 counts: &mut fallow_output::VitalSignsCounts,
5059 total_files_scoped: usize,
5060) -> Option<HealthScore> {
5061 if opts.score
5062 && let Some(report) = dupes_report
5063 {
5064 apply_duplication_metrics(vital_signs, counts, report);
5065 }
5066 opts.score
5067 .then(|| vital_signs::compute_health_score(vital_signs, total_files_scoped))
5068}
5069
5070#[derive(Clone, Copy)]
5071struct FilteredLargeFunctionInput<'a> {
5072 vital_signs: &'a fallow_output::VitalSigns,
5073 modules: &'a [crate::extract::ModuleInfo],
5074 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5075 config: &'a ResolvedConfig,
5076 ignore_set: &'a globset::GlobSet,
5077 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5078 ws_roots: Option<&'a [std::path::PathBuf]>,
5079 diff_index: Option<&'a fallow_output::DiffIndex>,
5080}
5081
5082fn collect_filtered_large_functions(
5083 input: FilteredLargeFunctionInput<'_>,
5084) -> Vec<fallow_output::LargeFunctionEntry> {
5085 let large_input = LargeFunctionInput {
5086 vital_signs: input.vital_signs,
5087 modules: input.modules,
5088 file_paths: input.file_paths,
5089 config_root: &input.config.root,
5090 ignore_set: input.ignore_set,
5091 changed_files: input.changed_files,
5092 ws_roots: input.ws_roots,
5093 };
5094 let mut large_functions = collect_large_functions(&large_input);
5095 if let Some(diff_index) = input.diff_index {
5096 filter_large_functions_by_diff(&mut large_functions, diff_index, &input.config.root);
5097 }
5098 large_functions
5099}
5100
5101fn filter_complexity_findings_by_diff(
5112 findings: &mut Vec<ComplexityViolation>,
5113 diff_index: &fallow_output::DiffIndex,
5114 root: &std::path::Path,
5115) {
5116 findings.retain(|f| {
5117 let Some(rel) = relative_to_root(&f.path, root) else {
5118 return true;
5119 };
5120 let start = u64::from(f.line);
5121 let end = if f.line_count == 0 {
5122 start
5123 } else {
5124 start + u64::from(f.line_count) - 1
5125 };
5126 diff_index.range_overlaps_added(&rel, start, end)
5127 });
5128}
5129
5130fn filter_hotspots_by_diff(
5136 hotspots: &mut Vec<fallow_output::HotspotEntry>,
5137 diff_index: &fallow_output::DiffIndex,
5138 root: &std::path::Path,
5139) {
5140 hotspots.retain(|h| match relative_to_root(&h.path, root) {
5141 Some(rel) => diff_index.touches_file(&rel),
5142 None => true,
5143 });
5144}
5145
5146fn filter_refactoring_targets_by_diff(
5153 targets: &mut Vec<fallow_output::RefactoringTarget>,
5154 diff_index: &fallow_output::DiffIndex,
5155 root: &std::path::Path,
5156) {
5157 targets.retain(|t| match relative_to_root(&t.path, root) {
5158 Some(rel) => diff_index.touches_file(&rel),
5159 None => true,
5160 });
5161}
5162
5163fn filter_large_functions_by_diff(
5167 entries: &mut Vec<fallow_output::LargeFunctionEntry>,
5168 diff_index: &fallow_output::DiffIndex,
5169 root: &std::path::Path,
5170) {
5171 entries.retain(|e| {
5172 let Some(rel) = relative_to_root(&e.path, root) else {
5173 return true;
5174 };
5175 let start = u64::from(e.line);
5176 let end = if e.line_count == 0 {
5177 start
5178 } else {
5179 start + u64::from(e.line_count) - 1
5180 };
5181 diff_index.range_overlaps_added(&rel, start, end)
5182 });
5183}
5184
5185fn collect_candidate_paths(
5186 files: &[fallow_types::discover::DiscoveredFile],
5187 config: &ResolvedConfig,
5188 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
5189 ws_roots: Option<&[std::path::PathBuf]>,
5190 ignore_set: &globset::GlobSet,
5191) -> rustc_hash::FxHashSet<std::path::PathBuf> {
5192 files
5193 .iter()
5194 .filter(|file| {
5195 path_in_health_scope(&file.path, config, changed_files, ws_roots, ignore_set)
5196 })
5197 .map(|file| file.path.clone())
5198 .collect()
5199}
5200
5201fn filter_files_to_paths(
5202 files: &[fallow_types::discover::DiscoveredFile],
5203 candidate_paths: &rustc_hash::FxHashSet<std::path::PathBuf>,
5204) -> Vec<fallow_types::discover::DiscoveredFile> {
5205 files
5206 .iter()
5207 .filter(|file| candidate_paths.contains(&file.path))
5208 .cloned()
5209 .collect()
5210}
5211
5212pub(crate) fn apply_duplication_metrics(
5213 vital_signs: &mut fallow_output::VitalSigns,
5214 counts: &mut fallow_output::VitalSignsCounts,
5215 dupes_report: &crate::duplicates::DuplicationReport,
5216) {
5217 let pct = dupes_report.stats.duplication_percentage;
5218 vital_signs.duplication_pct = Some((pct * 10.0).round() / 10.0);
5219 counts.duplicated_lines = Some(dupes_report.stats.duplicated_lines);
5220 if let Some(ref mut vc) = vital_signs.counts {
5221 vc.duplicated_lines = Some(dupes_report.stats.duplicated_lines);
5222 }
5223}
5224
5225fn sort_findings(findings: &mut [ComplexityViolation], sort: HealthSort) {
5227 match sort {
5228 HealthSort::Severity => findings.sort_by_key(|f| {
5229 std::cmp::Reverse((
5230 exceeded_priority(f.exceeded),
5231 severity_priority(f.severity),
5232 f.crap.is_some(),
5233 f.cyclomatic,
5234 f.cognitive,
5235 f.line_count,
5236 ))
5237 }),
5238 HealthSort::Cyclomatic => findings.sort_by_key(|f| std::cmp::Reverse(f.cyclomatic)),
5239 HealthSort::Cognitive => findings.sort_by_key(|f| std::cmp::Reverse(f.cognitive)),
5240 HealthSort::Lines => findings.sort_by_key(|f| std::cmp::Reverse(f.line_count)),
5241 }
5242}
5243
5244const fn exceeded_priority(exceeded: ExceededThreshold) -> u8 {
5245 match exceeded {
5246 ExceededThreshold::All => 5,
5247 ExceededThreshold::CyclomaticCrap | ExceededThreshold::CognitiveCrap => 4,
5248 ExceededThreshold::Crap => 3,
5249 ExceededThreshold::Both => 2,
5250 ExceededThreshold::Cyclomatic | ExceededThreshold::Cognitive => 1,
5251 }
5252}
5253
5254const fn severity_priority(severity: FindingSeverity) -> u8 {
5255 match severity {
5256 FindingSeverity::Critical => 3,
5257 FindingSeverity::High => 2,
5258 FindingSeverity::Moderate => 1,
5259 }
5260}
5261
5262type FileScoreResult = (Option<scoring::FileScoreOutput>, Option<usize>, Option<f64>);
5264
5265struct FileScoreInput<'a> {
5267 config: &'a ResolvedConfig,
5268 modules: &'a [crate::extract::ModuleInfo],
5269 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5270 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5271 ws_roots: Option<&'a [std::path::PathBuf]>,
5272 ignore_set: &'a globset::GlobSet,
5273 output: OutputFormat,
5274 istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
5275 pre_computed: Option<crate::DeadCodeAnalysisArtifacts>,
5276}
5277
5278fn compute_filtered_file_scores(input: FileScoreInput<'_>) -> Result<FileScoreResult, ExitCode> {
5279 let analysis_output = if let Some(pre) = input.pre_computed {
5280 pre
5281 } else {
5282 crate::analyze_with_parse_result(input.config, input.modules)
5283 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, input.output))?
5284 };
5285 match compute_file_scores(
5286 input.modules,
5287 input.file_paths,
5288 input.changed_files,
5289 analysis_output,
5290 input.istanbul_coverage,
5291 &input.config.root,
5292 ) {
5293 Ok(mut output) => {
5294 if let Some(ws) = input.ws_roots {
5295 output
5296 .scores
5297 .retain(|s| ws.iter().any(|r| s.path.starts_with(r)));
5298 }
5299 if !input.ignore_set.is_empty() {
5300 output.scores.retain(|s| {
5301 let relative = s.path.strip_prefix(&input.config.root).unwrap_or(&s.path);
5302 !input.ignore_set.is_match(relative)
5303 });
5304 }
5305 filter_coverage_gaps(
5306 &mut output.coverage.report,
5307 &mut output.coverage.runtime_paths,
5308 input.config,
5309 input.changed_files,
5310 input.ws_roots,
5311 input.ignore_set,
5312 );
5313 let total_scored = output.scores.len();
5314 let avg = if total_scored > 0 {
5315 let sum: f64 = output.scores.iter().map(|s| s.maintainability_index).sum();
5316 Some((sum / total_scored as f64 * 10.0).round() / 10.0)
5317 } else {
5318 None
5319 };
5320 Ok((Some(output), Some(total_scored), avg))
5321 }
5322 Err(e) => {
5323 eprintln!("Warning: failed to compute file scores: {e}");
5324 Ok((None, Some(0), None))
5325 }
5326 }
5327}
5328
5329fn compute_targets(
5331 input: &FilteredTargetInput<'_>,
5332) -> (Vec<RefactoringTarget>, Option<TargetThresholds>) {
5333 if !input.opts.targets {
5334 return (Vec::new(), None);
5335 }
5336 let Some(output) = input.score_output else {
5337 return (Vec::new(), None);
5338 };
5339 let clone_siblings = input
5340 .dupes_report
5341 .map_or_else(rustc_hash::FxHashMap::default, |report| {
5342 targets::build_clone_sibling_evidence(report)
5343 });
5344 let target_aux = TargetAuxData::from_output(output, &clone_siblings);
5345 let (mut tgts, thresholds) =
5346 compute_refactoring_targets(input.file_scores_slice, &target_aux, input.hotspots);
5347 if let Some(baseline) = input.loaded_baseline {
5348 tgts = filter_new_health_targets(tgts, baseline, &input.config.root);
5349 }
5350 if let Some(ref effort) = input.opts.effort {
5351 tgts.retain(|t| t.effort == *effort);
5352 }
5353 if let Some(top) = input.opts.top {
5354 tgts.truncate(top);
5355 }
5356 (tgts, Some(thresholds))
5357}
5358
5359fn path_in_health_scope(
5360 path: &std::path::Path,
5361 config: &ResolvedConfig,
5362 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
5363 ws_roots: Option<&[std::path::PathBuf]>,
5364 ignore_set: &globset::GlobSet,
5365) -> bool {
5366 if let Some(changed) = changed_files
5367 && !changed.contains(path)
5368 {
5369 return false;
5370 }
5371 if let Some(ws) = ws_roots
5372 && !ws.iter().any(|r| path.starts_with(r))
5373 {
5374 return false;
5375 }
5376 if !ignore_set.is_empty() {
5377 let relative = path.strip_prefix(&config.root).unwrap_or(path);
5378 if ignore_set.is_match(relative) {
5379 return false;
5380 }
5381 }
5382 true
5383}
5384
5385fn filter_coverage_gaps(
5386 coverage_gaps: &mut CoverageGaps,
5387 runtime_paths: &mut Vec<std::path::PathBuf>,
5388 config: &ResolvedConfig,
5389 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
5390 ws_roots: Option<&[std::path::PathBuf]>,
5391 ignore_set: &globset::GlobSet,
5392) {
5393 runtime_paths
5394 .retain(|path| path_in_health_scope(path, config, changed_files, ws_roots, ignore_set));
5395 coverage_gaps.files.retain(|item| {
5396 path_in_health_scope(&item.file.path, config, changed_files, ws_roots, ignore_set)
5397 });
5398 coverage_gaps.exports.retain(|item| {
5399 path_in_health_scope(
5400 &item.export.path,
5401 config,
5402 changed_files,
5403 ws_roots,
5404 ignore_set,
5405 )
5406 });
5407
5408 runtime_paths.sort();
5409 runtime_paths.dedup();
5410
5411 let runtime_files = runtime_paths.len();
5412 let untested_files = coverage_gaps.files.len();
5413 let covered_files = runtime_files.saturating_sub(untested_files);
5414 coverage_gaps.summary = scoring::build_coverage_summary(
5415 runtime_files,
5416 covered_files,
5417 untested_files,
5418 coverage_gaps.exports.len(),
5419 );
5420}
5421
5422pub enum SubsetFilter<'a> {
5430 Full,
5431 Paths(&'a rustc_hash::FxHashSet<std::path::PathBuf>),
5432}
5433
5434impl SubsetFilter<'_> {
5435 pub fn is_full(&self) -> bool {
5436 matches!(self, Self::Full)
5437 }
5438 pub fn matches(&self, path: &std::path::Path) -> bool {
5439 match self {
5440 Self::Full => true,
5441 Self::Paths(set) => set.contains(path),
5442 }
5443 }
5444}
5445
5446pub(crate) struct VitalSignsAndCountsInput<'a> {
5455 pub(crate) score_output: Option<&'a scoring::FileScoreOutput>,
5456 pub(crate) modules: &'a [crate::extract::ModuleInfo],
5457 pub(crate) file_paths:
5458 &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5459 pub(crate) needs_file_scores: bool,
5460 pub(crate) file_scores_slice: &'a [FileHealthScore],
5461 pub(crate) needs_hotspots: bool,
5462 pub(crate) hotspots: &'a [HotspotEntry],
5463 pub(crate) total_files: usize,
5464 pub(crate) subset: &'a SubsetFilter<'a>,
5465}
5466
5467pub(crate) fn compute_vital_signs_and_counts(
5468 input: &VitalSignsAndCountsInput<'_>,
5469) -> (fallow_output::VitalSigns, fallow_output::VitalSignsCounts) {
5470 let analysis_counts = input.score_output.map(|o| {
5471 o.analysis_snapshot
5472 .counts_for(input.subset, &o.analysis_counts)
5473 });
5474 let module_filter_set: Option<rustc_hash::FxHashSet<crate::discover::FileId>> =
5475 if input.subset.is_full() {
5476 None
5477 } else {
5478 Some(
5479 input
5480 .modules
5481 .iter()
5482 .filter_map(|m| {
5483 let path = input.file_paths.get(&m.file_id)?;
5484 if input.subset.matches(path) {
5485 Some(m.file_id)
5486 } else {
5487 None
5488 }
5489 })
5490 .collect(),
5491 )
5492 };
5493 let vs_input = vital_signs::VitalSignsInput {
5494 modules: input.modules,
5495 module_filter: module_filter_set.as_ref(),
5496 file_scores: if input.needs_file_scores {
5497 Some(input.file_scores_slice)
5498 } else {
5499 None
5500 },
5501 hotspots: if input.needs_hotspots {
5502 Some(input.hotspots)
5503 } else {
5504 None
5505 },
5506 total_files: input.total_files,
5507 analysis_counts,
5508 };
5509 let signs = vital_signs::compute_vital_signs(&vs_input);
5510 let counts = vital_signs::build_counts(&vs_input);
5511 (signs, counts)
5512}
5513
5514struct SnapshotInput<'a> {
5516 opts: &'a HealthOptions<'a>,
5517 snapshot_path: &'a std::path::Path,
5518 vital_signs: &'a fallow_output::VitalSigns,
5519 counts: &'a fallow_output::VitalSignsCounts,
5520 hotspot_summary: Option<&'a fallow_output::HotspotSummary>,
5521 health_score: Option<&'a fallow_output::HealthScore>,
5522 coverage_model: Option<fallow_output::CoverageModel>,
5523}
5524
5525fn save_snapshot(input: SnapshotInput<'_>) -> Result<(), ExitCode> {
5526 let shallow = input.hotspot_summary.is_some_and(|s| s.shallow_clone);
5527 let snapshot = vital_signs::build_snapshot(
5528 input.vital_signs.clone(),
5529 input.counts.clone(),
5530 input.opts.root,
5531 shallow,
5532 input.health_score,
5533 input.coverage_model,
5534 );
5535 let explicit = if input.snapshot_path.as_os_str().is_empty() {
5536 None
5537 } else {
5538 Some(input.snapshot_path)
5539 };
5540 match vital_signs::save_snapshot(&snapshot, input.opts.root, explicit) {
5541 Ok(saved_path) => {
5542 if !input.opts.quiet {
5543 eprintln!("Saved vital signs snapshot to {}", saved_path.display());
5544 }
5545 Ok(())
5546 }
5547 Err(e) => Err(emit_error(&e, 2, input.opts.output)),
5548 }
5549}
5550
5551fn compute_health_trend(
5553 opts: &HealthOptions<'_>,
5554 vital_signs: &fallow_output::VitalSigns,
5555 counts: &fallow_output::VitalSignsCounts,
5556 health_score: Option<&fallow_output::HealthScore>,
5557) -> Option<fallow_output::HealthTrend> {
5558 if !opts.trend {
5559 return None;
5560 }
5561 if opts.changed_since.is_some() && !opts.quiet {
5562 eprintln!(
5563 "warning: --trend comparison may be inaccurate with --changed-since; \
5564 snapshots are typically from full-project runs"
5565 );
5566 }
5567 let snapshots = vital_signs::load_snapshots(opts.root);
5568 if snapshots.is_empty() && !opts.quiet {
5569 eprintln!(
5570 "No snapshots found. Run `fallow health --save-snapshot` to save a \
5571 baseline, then use --trend on subsequent runs to track progress."
5572 );
5573 }
5574 vital_signs::compute_trend(
5575 vital_signs,
5576 counts,
5577 health_score.map(|s| s.score),
5578 &snapshots,
5579 )
5580}
5581
5582pub(crate) struct HealthReportAssembly {
5583 pub(crate) report_coverage_gaps: bool,
5584 pub(crate) findings: Vec<ComplexityViolation>,
5585 pub(crate) threshold_overrides: Vec<fallow_output::ThresholdOverrideState>,
5586 pub(crate) files_analyzed: usize,
5587 pub(crate) total_functions: usize,
5588 pub(crate) total_above_threshold: usize,
5589 pub(crate) max_cyclomatic: u16,
5590 pub(crate) max_cognitive: u16,
5591 pub(crate) max_crap: f64,
5592 pub(crate) files_scored: Option<usize>,
5593 pub(crate) average_maintainability: Option<f64>,
5594 pub(crate) vital_signs: fallow_output::VitalSigns,
5595 pub(crate) health_score: Option<fallow_output::HealthScore>,
5596 pub(crate) score_output: Option<scoring::FileScoreOutput>,
5597 pub(crate) hotspots: Vec<HotspotEntry>,
5598 pub(crate) hotspot_summary: Option<fallow_output::HotspotSummary>,
5599 pub(crate) targets: Vec<RefactoringTarget>,
5600 pub(crate) target_thresholds: Option<TargetThresholds>,
5601 pub(crate) health_trend: Option<fallow_output::HealthTrend>,
5602 pub(crate) has_istanbul_coverage: bool,
5603 pub(crate) runtime_coverage: Option<fallow_output::RuntimeCoverageReport>,
5604 pub(crate) framework_health: Option<fallow_output::FrameworkHealthDiagnostics>,
5605 pub(crate) large_functions: Vec<LargeFunctionEntry>,
5606 pub(crate) sev_critical: usize,
5607 pub(crate) sev_high: usize,
5608 pub(crate) sev_moderate: usize,
5609}
5610
5611struct LargeFunctionInput<'a> {
5616 vital_signs: &'a fallow_output::VitalSigns,
5617 modules: &'a [crate::extract::ModuleInfo],
5618 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5619 config_root: &'a std::path::Path,
5620 ignore_set: &'a globset::GlobSet,
5621 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5622 ws_roots: Option<&'a [std::path::PathBuf]>,
5623}
5624
5625fn collect_large_functions(input: &LargeFunctionInput<'_>) -> Vec<LargeFunctionEntry> {
5626 let dominated = input
5627 .vital_signs
5628 .unit_size_profile
5629 .as_ref()
5630 .is_some_and(|p| p.very_high_risk >= 3.0);
5631 if !dominated {
5632 return Vec::new();
5633 }
5634
5635 let mut entries = Vec::new();
5636 for module in input.modules {
5637 let Some(&path) = input.file_paths.get(&module.file_id) else {
5638 continue;
5639 };
5640 let relative = path.strip_prefix(input.config_root).unwrap_or(path);
5641 if input.ignore_set.is_match(relative) {
5642 continue;
5643 }
5644 if let Some(changed) = input.changed_files
5645 && !changed.contains(path.as_path())
5646 {
5647 continue;
5648 }
5649 if let Some(ws) = input.ws_roots
5650 && !ws.iter().any(|r| path.starts_with(r))
5651 {
5652 continue;
5653 }
5654 for func in &module.complexity {
5655 if func.line_count > 60 {
5656 entries.push(LargeFunctionEntry {
5657 path: path.clone(),
5658 name: func.name.clone(),
5659 line: func.line,
5660 line_count: func.line_count,
5661 });
5662 }
5663 }
5664 }
5665 entries.sort_by_key(|e| std::cmp::Reverse(e.line_count));
5666 entries
5667}
5668
5669#[expect(
5674 clippy::expect_used,
5675 reason = "health ignore globs are validated before health analysis"
5676)]
5677fn build_ignore_set(patterns: &[String]) -> globset::GlobSet {
5678 let mut builder = globset::GlobSetBuilder::new();
5679 for pattern in patterns {
5680 builder.add(
5681 globset::Glob::new(pattern)
5682 .expect("health.ignore pattern was validated at config load time"),
5683 );
5684 }
5685 builder
5686 .build()
5687 .unwrap_or_else(|_| globset::GlobSet::empty())
5688}
5689
5690#[expect(
5695 clippy::too_many_arguments,
5696 reason = "filter pipeline mirrors compute_filtered_file_scores"
5697)]
5698#[cfg(test)]
5699fn collect_findings(
5700 modules: &[crate::extract::ModuleInfo],
5701 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
5702 config_root: &std::path::Path,
5703 ignore_set: &globset::GlobSet,
5704 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
5705 ws_roots: Option<&[std::path::PathBuf]>,
5706 max_cyclomatic: u16,
5707 max_cognitive: u16,
5708 complexity_breakdown: bool,
5709) -> (Vec<ComplexityViolation>, usize, usize) {
5710 let global = GlobalHealthThresholds {
5711 cyclomatic: max_cyclomatic,
5712 cognitive: max_cognitive,
5713 crap: 30.0,
5714 };
5715 let resolver = ThresholdOverrideResolver::new(&[], global);
5716 let mut tracker = ThresholdOverrideStateTracker::default();
5717 let mut input = CollectFindingsInput {
5718 modules,
5719 file_paths,
5720 config_root,
5721 ignore_set,
5722 changed_files,
5723 ws_roots,
5724 threshold_resolver: &resolver,
5725 threshold_state_tracker: &mut tracker,
5726 complexity_breakdown,
5727 };
5728 collect_findings_with_resolver(&mut input)
5729}
5730
5731struct CollectFindingsInput<'a> {
5732 modules: &'a [crate::extract::ModuleInfo],
5733 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5734 config_root: &'a std::path::Path,
5735 ignore_set: &'a globset::GlobSet,
5736 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5737 ws_roots: Option<&'a [std::path::PathBuf]>,
5738 threshold_resolver: &'a ThresholdOverrideResolver,
5739 threshold_state_tracker: &'a mut ThresholdOverrideStateTracker,
5740 complexity_breakdown: bool,
5741}
5742
5743fn collect_findings_with_resolver(
5744 input: &mut CollectFindingsInput<'_>,
5745) -> (Vec<ComplexityViolation>, usize, usize) {
5746 let mut files_analyzed = 0usize;
5747 let mut total_functions = 0usize;
5748 let mut findings: Vec<ComplexityViolation> = Vec::new();
5749
5750 for module in input.modules {
5751 let Some((path, relative)) = collect_findings_module_path(input, module) else {
5752 continue;
5753 };
5754
5755 files_analyzed += 1;
5756 let hook_profiles = react_hooks::build_module_hook_profiles(module);
5761 for (fc_idx, fc) in module.complexity.iter().enumerate() {
5762 total_functions += 1;
5763 if crate::suppress::is_suppressed(
5764 &module.suppressions,
5765 fc.line,
5766 crate::suppress::IssueKind::Complexity,
5767 ) {
5768 continue;
5769 }
5770 let react_hook_profile = hook_profiles.get(fc_idx).cloned().flatten();
5771 if let Some(finding) =
5772 collect_complexity_finding(input, path, relative, fc, react_hook_profile)
5773 {
5774 findings.push(finding);
5775 }
5776 }
5777 }
5778
5779 (findings, files_analyzed, total_functions)
5780}
5781
5782fn collect_findings_module_path<'a>(
5783 input: &CollectFindingsInput<'a>,
5784 module: &crate::extract::ModuleInfo,
5785) -> Option<(&'a std::path::PathBuf, &'a std::path::Path)> {
5786 let &path = input.file_paths.get(&module.file_id)?;
5787 let relative = path.strip_prefix(input.config_root).unwrap_or(path);
5788 if input.ignore_set.is_match(relative) {
5789 return None;
5790 }
5791 if let Some(changed) = input.changed_files
5792 && !changed.contains(path)
5793 {
5794 return None;
5795 }
5796 if let Some(ws) = input.ws_roots
5797 && !ws.iter().any(|root| path.starts_with(root))
5798 {
5799 return None;
5800 }
5801 Some((path, relative))
5802}
5803
5804fn collect_complexity_finding(
5805 input: &mut CollectFindingsInput<'_>,
5806 path: &std::path::Path,
5807 relative: &std::path::Path,
5808 fc: &fallow_types::extract::FunctionComplexity,
5809 react_hook_profile: Option<fallow_output::ReactHookProfile>,
5810) -> Option<ComplexityViolation> {
5811 let (applied_thresholds, matched_overrides) =
5812 input.threshold_resolver.resolve(relative, &fc.name);
5813 input.threshold_state_tracker.record_complexity(
5814 ComplexityFunctionContext {
5815 path,
5816 function: &fc.name,
5817 cyclomatic: fc.cyclomatic,
5818 cognitive: fc.cognitive,
5819 },
5820 &matched_overrides,
5821 input.threshold_resolver.global,
5822 );
5823 let exceeds_cyclomatic = fc.cyclomatic > applied_thresholds.effective.max_cyclomatic;
5824 let exceeds_cognitive = fc.cognitive > applied_thresholds.effective.max_cognitive;
5825 if !exceeds_cyclomatic && !exceeds_cognitive {
5826 return None;
5827 }
5828
5829 Some(ComplexityViolation {
5830 path: path.to_path_buf(),
5831 name: fc.name.clone(),
5832 line: fc.line,
5833 col: fc.col,
5834 cyclomatic: fc.cyclomatic,
5835 cognitive: fc.cognitive,
5836 line_count: fc.line_count,
5837 param_count: fc.param_count,
5838 react_hook_count: fc.react_hook_count,
5839 react_jsx_max_depth: fc.react_jsx_max_depth,
5840 react_prop_count: fc.react_prop_count,
5841 react_hook_profile,
5842 exceeded: ExceededThreshold::from_bools(exceeds_cyclomatic, exceeds_cognitive, false),
5843 severity: compute_finding_severity(
5844 fc.cognitive,
5845 fc.cyclomatic,
5846 None,
5847 DEFAULT_COGNITIVE_HIGH,
5848 DEFAULT_COGNITIVE_CRITICAL,
5849 DEFAULT_CYCLOMATIC_HIGH,
5850 DEFAULT_CYCLOMATIC_CRITICAL,
5851 ),
5852 crap: None,
5853 coverage_pct: None,
5854 coverage_tier: None,
5855 coverage_source: None,
5856 inherited_from: None,
5857 component_rollup: None,
5858 contributions: contributions_for(input.complexity_breakdown, fc),
5859 effective_thresholds: applied_thresholds
5860 .override_index
5861 .map(|_| applied_thresholds.effective),
5862 threshold_source: applied_thresholds
5863 .override_index
5864 .map(|_| fallow_output::ThresholdSource::Override),
5865 })
5866}
5867
5868fn contributions_for(
5872 complexity_breakdown: bool,
5873 fc: &fallow_types::extract::FunctionComplexity,
5874) -> Vec<fallow_types::extract::ComplexityContribution> {
5875 if complexity_breakdown {
5876 fc.contributions.clone()
5877 } else {
5878 Vec::new()
5879 }
5880}
5881
5882struct CrapFindingMergeInput<'a> {
5890 modules: &'a [crate::extract::ModuleInfo],
5891 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5892 config_root: &'a std::path::Path,
5893 ignore_set: &'a globset::GlobSet,
5894 changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5895 ws_roots: Option<&'a [std::path::PathBuf]>,
5896 per_function_crap: &'a rustc_hash::FxHashMap<std::path::PathBuf, Vec<scoring::PerFunctionCrap>>,
5897 template_inherit_provenance: &'a rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
5898 complexity_breakdown: bool,
5899 threshold_resolver: &'a ThresholdOverrideResolver,
5900 threshold_state_tracker: &'a mut ThresholdOverrideStateTracker,
5901}
5902
5903type ComplexityByPosition<'a> = rustc_hash::FxHashMap<
5904 &'a std::path::Path,
5905 rustc_hash::FxHashMap<(u32, u32), &'a fallow_types::extract::FunctionComplexity>,
5906>;
5907
5908struct CrapMergeMaps<'a> {
5912 finding_index: rustc_hash::FxHashMap<(std::path::PathBuf, u32, u32), usize>,
5913 complexity_by_pos: ComplexityByPosition<'a>,
5914 hook_profiles_by_pos: rustc_hash::FxHashMap<
5915 &'a std::path::Path,
5916 rustc_hash::FxHashMap<(u32, u32), fallow_output::ReactHookProfile>,
5917 >,
5918 suppressions_by_path:
5919 rustc_hash::FxHashMap<&'a std::path::Path, &'a Vec<crate::suppress::Suppression>>,
5920}
5921
5922fn process_crap_findings_for_path(
5926 path: &std::path::Path,
5927 per_fn: &[scoring::PerFunctionCrap],
5928 maps: &CrapMergeMaps<'_>,
5929 findings: &mut [ComplexityViolation],
5930 new_findings: &mut Vec<ComplexityViolation>,
5931 input: &mut CrapFindingMergeInput<'_>,
5932) {
5933 for pf in per_fn {
5934 let Some(fc) = maps
5935 .complexity_by_pos
5936 .get(path)
5937 .and_then(|m| m.get(&(pf.line, pf.col)).copied())
5938 else {
5939 continue;
5940 };
5941 let relative = path.strip_prefix(input.config_root).unwrap_or(path);
5942 let (applied_thresholds, matched_overrides) =
5943 input.threshold_resolver.resolve(relative, &fc.name);
5944 input.threshold_state_tracker.record_crap(
5945 path,
5946 &fc.name,
5947 MeasuredThresholdMetrics {
5948 cyclomatic: fc.cyclomatic,
5949 cognitive: fc.cognitive,
5950 crap: pf.crap,
5951 },
5952 &matched_overrides,
5953 input.threshold_resolver.global,
5954 );
5955 if pf.crap < applied_thresholds.effective.max_crap
5956 || crap_is_suppressed(path, pf, &maps.suppressions_by_path)
5957 {
5958 continue;
5959 }
5960
5961 if let Some(&idx) = maps
5962 .finding_index
5963 .get(&(path.to_path_buf(), pf.line, pf.col))
5964 {
5965 merge_existing_crap_finding(&mut findings[idx], path, pf, input, applied_thresholds);
5966 } else {
5967 let hook_profile = maps
5968 .hook_profiles_by_pos
5969 .get(path)
5970 .and_then(|m| m.get(&(pf.line, pf.col)).cloned());
5971 new_findings.push(new_crap_finding(
5972 path,
5973 pf,
5974 fc,
5975 hook_profile,
5976 input,
5977 applied_thresholds,
5978 ));
5979 }
5980 }
5981}
5982
5983fn merge_crap_findings(
5984 findings: &mut Vec<ComplexityViolation>,
5985 input: &mut CrapFindingMergeInput<'_>,
5986) {
5987 let modules = input.modules;
5991 let file_paths = input.file_paths;
5992 let per_function_crap = input.per_function_crap;
5993 let maps = CrapMergeMaps {
5994 finding_index: build_complexity_finding_index(findings),
5995 complexity_by_pos: build_complexity_by_position(modules, file_paths),
5996 hook_profiles_by_pos: build_hook_profiles_by_position(modules, file_paths),
5997 suppressions_by_path: build_complexity_suppressions_by_path(modules, file_paths),
5998 };
5999
6000 let mut new_findings: Vec<ComplexityViolation> = Vec::new();
6001 for (path, per_fn) in per_function_crap {
6002 if !crap_path_in_scope(path, input) {
6003 continue;
6004 }
6005 process_crap_findings_for_path(path, per_fn, &maps, findings, &mut new_findings, input);
6006 }
6007 findings.extend(new_findings);
6008}
6009
6010fn build_complexity_finding_index(
6011 findings: &[ComplexityViolation],
6012) -> rustc_hash::FxHashMap<(std::path::PathBuf, u32, u32), usize> {
6013 findings
6014 .iter()
6015 .enumerate()
6016 .map(|(idx, f)| ((f.path.clone(), f.line, f.col), idx))
6017 .collect()
6018}
6019
6020fn build_complexity_by_position<'a>(
6021 modules: &'a [crate::extract::ModuleInfo],
6022 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
6023) -> ComplexityByPosition<'a> {
6024 let mut complexity_by_pos: ComplexityByPosition<'a> = rustc_hash::FxHashMap::default();
6025 for module in modules {
6026 let Some(&path) = file_paths.get(&module.file_id) else {
6027 continue;
6028 };
6029 let entry = complexity_by_pos.entry(path.as_path()).or_default();
6030 for fc in &module.complexity {
6031 entry.insert((fc.line, fc.col), fc);
6032 }
6033 }
6034 complexity_by_pos
6035}
6036
6037fn build_hook_profiles_by_position<'a>(
6043 modules: &'a [crate::extract::ModuleInfo],
6044 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
6045) -> rustc_hash::FxHashMap<
6046 &'a std::path::Path,
6047 rustc_hash::FxHashMap<(u32, u32), fallow_output::ReactHookProfile>,
6048> {
6049 let mut by_pos: rustc_hash::FxHashMap<
6050 &'a std::path::Path,
6051 rustc_hash::FxHashMap<(u32, u32), fallow_output::ReactHookProfile>,
6052 > = rustc_hash::FxHashMap::default();
6053 for module in modules {
6054 let Some(&path) = file_paths.get(&module.file_id) else {
6055 continue;
6056 };
6057 let profiles = react_hooks::build_module_hook_profiles(module);
6058 let mut frame_profiles = rustc_hash::FxHashMap::default();
6059 for (fc, profile) in module.complexity.iter().zip(profiles) {
6060 if let Some(profile) = profile {
6061 frame_profiles.insert((fc.line, fc.col), profile);
6062 }
6063 }
6064 if !frame_profiles.is_empty() {
6065 by_pos.insert(path.as_path(), frame_profiles);
6066 }
6067 }
6068 by_pos
6069}
6070
6071fn build_complexity_suppressions_by_path<'a>(
6072 modules: &'a [crate::extract::ModuleInfo],
6073 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
6074) -> rustc_hash::FxHashMap<&'a std::path::Path, &'a Vec<crate::suppress::Suppression>> {
6075 modules
6076 .iter()
6077 .filter_map(|module| {
6078 file_paths
6079 .get(&module.file_id)
6080 .map(|path| (path.as_path(), &module.suppressions))
6081 })
6082 .collect()
6083}
6084
6085fn crap_path_in_scope(path: &std::path::Path, input: &CrapFindingMergeInput<'_>) -> bool {
6086 let relative = path.strip_prefix(input.config_root).unwrap_or(path);
6087 if input.ignore_set.is_match(relative) {
6088 return false;
6089 }
6090 if let Some(changed) = input.changed_files
6091 && !changed.contains(path)
6092 {
6093 return false;
6094 }
6095 if let Some(ws) = input.ws_roots
6096 && !ws.iter().any(|r| path.starts_with(r))
6097 {
6098 return false;
6099 }
6100 true
6101}
6102
6103fn crap_is_suppressed(
6104 path: &std::path::Path,
6105 pf: &scoring::PerFunctionCrap,
6106 suppressions_by_path: &rustc_hash::FxHashMap<
6107 &std::path::Path,
6108 &Vec<crate::suppress::Suppression>,
6109 >,
6110) -> bool {
6111 suppressions_by_path.get(path).is_some_and(|sups| {
6112 crate::suppress::is_suppressed(sups, pf.line, crate::suppress::IssueKind::Complexity)
6113 })
6114}
6115
6116fn merge_existing_crap_finding(
6117 finding: &mut ComplexityViolation,
6118 path: &std::path::Path,
6119 pf: &scoring::PerFunctionCrap,
6120 input: &CrapFindingMergeInput<'_>,
6121 applied_thresholds: AppliedHealthThresholds,
6122) {
6123 finding.crap = Some(pf.crap);
6124 finding.coverage_pct = pf.coverage_pct;
6125 finding.coverage_tier = Some(pf.coverage_tier);
6126 finding.coverage_source = Some(pf.coverage_source);
6127 finding.inherited_from =
6128 inherited_from_for(pf.coverage_source, path, input.template_inherit_provenance);
6129 let exceeds_cyclomatic = finding.exceeded.includes_cyclomatic();
6130 let exceeds_cognitive = finding.exceeded.includes_cognitive();
6131 finding.exceeded = ExceededThreshold::from_bools(exceeds_cyclomatic, exceeds_cognitive, true);
6132 if applied_thresholds.override_index.is_some() {
6133 finding.effective_thresholds = Some(applied_thresholds.effective);
6134 finding.threshold_source = Some(fallow_output::ThresholdSource::Override);
6135 }
6136 finding.severity = compute_finding_severity(
6137 finding.cognitive,
6138 finding.cyclomatic,
6139 Some(pf.crap),
6140 DEFAULT_COGNITIVE_HIGH,
6141 DEFAULT_COGNITIVE_CRITICAL,
6142 DEFAULT_CYCLOMATIC_HIGH,
6143 DEFAULT_CYCLOMATIC_CRITICAL,
6144 );
6145}
6146
6147fn new_crap_finding(
6148 path: &std::path::Path,
6149 pf: &scoring::PerFunctionCrap,
6150 fc: &fallow_types::extract::FunctionComplexity,
6151 hook_profile: Option<fallow_output::ReactHookProfile>,
6152 input: &CrapFindingMergeInput<'_>,
6153 applied_thresholds: AppliedHealthThresholds,
6154) -> ComplexityViolation {
6155 let exceeds_cyclomatic = fc.cyclomatic > applied_thresholds.effective.max_cyclomatic;
6156 let exceeds_cognitive = fc.cognitive > applied_thresholds.effective.max_cognitive;
6157 ComplexityViolation {
6158 path: path.to_path_buf(),
6159 name: fc.name.clone(),
6160 line: fc.line,
6161 col: fc.col,
6162 cyclomatic: fc.cyclomatic,
6163 cognitive: fc.cognitive,
6164 line_count: fc.line_count,
6165 param_count: fc.param_count,
6166 react_hook_count: fc.react_hook_count,
6167 react_jsx_max_depth: fc.react_jsx_max_depth,
6168 react_prop_count: fc.react_prop_count,
6169 react_hook_profile: hook_profile,
6170 exceeded: ExceededThreshold::from_bools(exceeds_cyclomatic, exceeds_cognitive, true),
6171 severity: compute_finding_severity(
6172 fc.cognitive,
6173 fc.cyclomatic,
6174 Some(pf.crap),
6175 DEFAULT_COGNITIVE_HIGH,
6176 DEFAULT_COGNITIVE_CRITICAL,
6177 DEFAULT_CYCLOMATIC_HIGH,
6178 DEFAULT_CYCLOMATIC_CRITICAL,
6179 ),
6180 crap: Some(pf.crap),
6181 coverage_pct: pf.coverage_pct,
6182 coverage_tier: Some(pf.coverage_tier),
6183 coverage_source: Some(pf.coverage_source),
6184 inherited_from: inherited_from_for(
6185 pf.coverage_source,
6186 path,
6187 input.template_inherit_provenance,
6188 ),
6189 component_rollup: None,
6190 contributions: contributions_for(input.complexity_breakdown, fc),
6191 effective_thresholds: applied_thresholds
6192 .override_index
6193 .map(|_| applied_thresholds.effective),
6194 threshold_source: applied_thresholds
6195 .override_index
6196 .map(|_| fallow_output::ThresholdSource::Override),
6197 }
6198}
6199
6200fn append_component_rollup_findings(
6236 findings: &mut Vec<fallow_output::ComplexityViolation>,
6237 template_owner_lookup: Option<&rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>>,
6238 max_cyclomatic: u16,
6239 max_cognitive: u16,
6240) {
6241 use fallow_output::ComplexityViolation;
6242
6243 let mut by_owner: rustc_hash::FxHashMap<std::path::PathBuf, (Vec<usize>, Vec<usize>)> =
6244 rustc_hash::FxHashMap::default();
6245 for (idx, f) in findings.iter().enumerate() {
6246 if f.name == "<template>" {
6247 if let Some(owner) = component_template_owner(f, template_owner_lookup) {
6248 by_owner.entry(owner).or_default().1.push(idx);
6249 }
6250 } else if is_component_class_finding(f) {
6251 by_owner.entry(f.path.clone()).or_default().0.push(idx);
6252 }
6253 }
6254
6255 let mut to_push: Vec<ComplexityViolation> = Vec::new();
6256 for (owner, (class_idxs, template_idxs)) in by_owner {
6257 if class_idxs.is_empty() || template_idxs.is_empty() {
6258 continue;
6259 }
6260 if template_idxs.len() > 1 {
6261 continue;
6262 }
6263 let template = &findings[template_idxs[0]];
6264 let Some(worst_idx) = class_idxs
6265 .iter()
6266 .copied()
6267 .max_by_key(|&i| findings[i].cyclomatic)
6268 else {
6269 continue;
6270 };
6271 let worst = &findings[worst_idx];
6272 if let Some(rollup) =
6273 build_component_rollup(owner, worst, template, max_cyclomatic, max_cognitive)
6274 {
6275 to_push.push(rollup);
6276 }
6277 }
6278 findings.extend(to_push);
6279}
6280
6281fn component_template_owner(
6282 finding: &fallow_output::ComplexityViolation,
6283 template_owner_lookup: Option<&rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>>,
6284) -> Option<std::path::PathBuf> {
6285 let ext = finding
6286 .path
6287 .extension()
6288 .and_then(|e| e.to_str())
6289 .map(str::to_ascii_lowercase);
6290 match ext.as_deref() {
6291 Some("html") => template_owner_lookup
6292 .and_then(|m| m.get(&finding.path))
6293 .cloned(),
6294 Some("ts" | "tsx" | "mts" | "cts") => Some(finding.path.clone()),
6295 _ => None,
6296 }
6297}
6298
6299fn is_component_class_finding(finding: &fallow_output::ComplexityViolation) -> bool {
6300 finding.name != "<component>"
6301 && finding
6302 .path
6303 .extension()
6304 .and_then(|e| e.to_str())
6305 .is_some_and(|ext| {
6306 matches!(
6307 ext.to_ascii_lowercase().as_str(),
6308 "ts" | "tsx" | "mts" | "cts"
6309 )
6310 })
6311}
6312
6313struct ComponentRollupTotals {
6316 rollup_cyc: u16,
6317 rollup_cog: u16,
6318 exceeds_cyclomatic: bool,
6319 exceeds_cognitive: bool,
6320}
6321
6322fn make_component_rollup_violation(
6325 owner: std::path::PathBuf,
6326 worst: &fallow_output::ComplexityViolation,
6327 template: &fallow_output::ComplexityViolation,
6328 totals: &ComponentRollupTotals,
6329) -> fallow_output::ComplexityViolation {
6330 use fallow_output::{ComponentRollup, ExceededThreshold};
6331
6332 let component = owner.file_stem().map_or_else(
6333 || "<unknown-component>".to_string(),
6334 |stem| stem.to_string_lossy().into_owned(),
6335 );
6336 fallow_output::ComplexityViolation {
6337 path: owner,
6338 name: "<component>".to_string(),
6339 line: worst.line,
6340 col: worst.col,
6341 cyclomatic: totals.rollup_cyc,
6342 cognitive: totals.rollup_cog,
6343 line_count: worst.line_count.saturating_add(template.line_count),
6344 param_count: 0,
6345 exceeded: ExceededThreshold::from_bools(
6346 totals.exceeds_cyclomatic,
6347 totals.exceeds_cognitive,
6348 false,
6349 ),
6350 severity: compute_finding_severity(
6351 totals.rollup_cog,
6352 totals.rollup_cyc,
6353 None,
6354 DEFAULT_COGNITIVE_HIGH,
6355 DEFAULT_COGNITIVE_CRITICAL,
6356 DEFAULT_CYCLOMATIC_HIGH,
6357 DEFAULT_CYCLOMATIC_CRITICAL,
6358 ),
6359 crap: None,
6360 coverage_pct: None,
6361 coverage_tier: None,
6362 coverage_source: None,
6363 inherited_from: None,
6364 react_hook_count: 0,
6365 react_jsx_max_depth: 0,
6366 react_prop_count: 0,
6367 react_hook_profile: None,
6368 component_rollup: Some(ComponentRollup {
6369 component,
6370 class_worst_function: worst.name.clone(),
6371 class_cyclomatic: worst.cyclomatic,
6372 class_cognitive: worst.cognitive,
6373 template_path: template.path.clone(),
6374 template_cyclomatic: template.cyclomatic,
6375 template_cognitive: template.cognitive,
6376 }),
6377 contributions: Vec::new(),
6378 effective_thresholds: None,
6379 threshold_source: None,
6380 }
6381}
6382
6383fn build_component_rollup(
6384 owner: std::path::PathBuf,
6385 worst: &fallow_output::ComplexityViolation,
6386 template: &fallow_output::ComplexityViolation,
6387 max_cyclomatic: u16,
6388 max_cognitive: u16,
6389) -> Option<fallow_output::ComplexityViolation> {
6390 let rollup_cyc = worst.cyclomatic.saturating_add(template.cyclomatic);
6391 let rollup_cog = worst.cognitive.saturating_add(template.cognitive);
6392 let exceeds_cyclomatic = rollup_cyc > max_cyclomatic;
6393 let exceeds_cognitive = rollup_cog > max_cognitive;
6394 if !exceeds_cyclomatic && !exceeds_cognitive {
6395 return None;
6396 }
6397
6398 let totals = ComponentRollupTotals {
6399 rollup_cyc,
6400 rollup_cog,
6401 exceeds_cyclomatic,
6402 exceeds_cognitive,
6403 };
6404 Some(make_component_rollup_violation(
6405 owner, worst, template, &totals,
6406 ))
6407}
6408
6409fn inherited_from_for(
6418 source: fallow_output::CoverageSource,
6419 template_path: &std::path::Path,
6420 template_inherit_provenance: &rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
6421) -> Option<std::path::PathBuf> {
6422 if matches!(
6423 source,
6424 fallow_output::CoverageSource::EstimatedComponentInherited
6425 ) {
6426 template_inherit_provenance.get(template_path).cloned()
6427 } else {
6428 None
6429 }
6430}
6431
6432struct HealthBaselineSaveInput<'a> {
6433 save_path: &'a std::path::Path,
6434 findings: &'a [ComplexityViolation],
6435 runtime_coverage_findings: &'a [fallow_output::RuntimeCoverageFinding],
6436 targets: &'a [RefactoringTarget],
6437 config_root: &'a std::path::Path,
6438 quiet: bool,
6439 output: OutputFormat,
6440}
6441
6442fn save_health_baseline(input: &HealthBaselineSaveInput<'_>) -> Result<(), ExitCode> {
6444 let HealthBaselineSaveInput {
6445 save_path,
6446 findings,
6447 runtime_coverage_findings,
6448 targets,
6449 config_root,
6450 quiet,
6451 output,
6452 } = *input;
6453 let baseline = HealthBaselineData::from_findings(
6454 findings,
6455 runtime_coverage_findings,
6456 targets,
6457 config_root,
6458 );
6459 match serde_json::to_string_pretty(&baseline) {
6460 Ok(json) => {
6461 if let Some(parent) = save_path.parent()
6462 && !parent.as_os_str().is_empty()
6463 && let Err(e) = std::fs::create_dir_all(parent)
6464 {
6465 return Err(emit_error(
6466 &format!("failed to create health baseline directory: {e}"),
6467 2,
6468 output,
6469 ));
6470 }
6471 if let Err(e) = std::fs::write(save_path, json) {
6472 return Err(emit_error(
6473 &format!("failed to save health baseline: {e}"),
6474 2,
6475 output,
6476 ));
6477 }
6478 if !quiet {
6479 eprintln!("Saved health baseline to {}", save_path.display());
6480 }
6481 Ok(())
6482 }
6483 Err(e) => Err(emit_error(
6484 &format!("failed to serialize health baseline: {e}"),
6485 2,
6486 output,
6487 )),
6488 }
6489}
6490
6491fn load_health_baseline(
6493 baseline_path: &std::path::Path,
6494 findings: &mut Vec<ComplexityViolation>,
6495 root: &std::path::Path,
6496 quiet: bool,
6497 output: OutputFormat,
6498) -> Result<HealthBaselineData, ExitCode> {
6499 let json = std::fs::read_to_string(baseline_path)
6500 .map_err(|e| emit_error(&format!("failed to read health baseline: {e}"), 2, output))?;
6501 let baseline: HealthBaselineData = serde_json::from_str(&json)
6502 .map_err(|e| emit_error(&format!("failed to parse health baseline: {e}"), 2, output))?;
6503 let baseline_entries = baseline.finding_entry_count();
6504 let before = findings.len();
6505 let overlap_entries = baseline.overlap_entry_count(findings, root);
6506 *findings = filter_new_health_findings(std::mem::take(findings), &baseline, root);
6507 if !quiet {
6508 eprintln!(
6509 "Comparing against health baseline: {}",
6510 baseline_path.display()
6511 );
6512 }
6513 if baseline_entries > 0 && before > 0 && overlap_entries == 0 && !quiet {
6514 eprintln!(
6515 "Warning: health baseline has {baseline_entries} entries but matched \
6516 0 current findings. Your paths may have changed, or the baseline \
6517 was saved on a different machine. Re-save with: \
6518 --save-baseline {}",
6519 baseline_path.display(),
6520 );
6521 }
6522 Ok(baseline)
6523}
6524
6525#[cfg(test)]
6526mod tests {
6527 use super::*;
6528 use crate::extract::ModuleInfo;
6529 use fallow_types::discover::FileId;
6530 use fallow_types::extract::FunctionComplexity;
6531 use rustc_hash::{FxHashMap, FxHashSet};
6532 use std::path::{Path, PathBuf};
6533
6534 fn make_module(file_id: FileId, complexity: Vec<FunctionComplexity>) -> ModuleInfo {
6536 ModuleInfo {
6537 file_id,
6538 exports: vec![],
6539 imports: vec![],
6540 re_exports: vec![],
6541 dynamic_imports: vec![],
6542 dynamic_import_patterns: vec![],
6543 require_calls: vec![],
6544 package_path_references: Box::default(),
6545 member_accesses: vec![],
6546 semantic_facts: Box::default(),
6547 whole_object_uses: Box::default(),
6548 has_cjs_exports: false,
6549 has_angular_component_template_url: false,
6550 content_hash: 0,
6551 suppressions: vec![],
6552 unknown_suppression_kinds: vec![],
6553 unused_import_bindings: vec![],
6554 type_referenced_import_bindings: vec![],
6555 value_referenced_import_bindings: vec![],
6556 line_offsets: vec![0],
6557 complexity,
6558 flag_uses: vec![],
6559 class_heritage: vec![],
6560 exported_factory_returns: Box::default(),
6561 injection_tokens: vec![],
6562 local_type_declarations: Vec::new(),
6563 public_signature_type_references: Vec::new(),
6564 namespace_object_aliases: Vec::new(),
6565 iconify_prefixes: Vec::new(),
6566 iconify_icon_names: Vec::new(),
6567 auto_import_candidates: Vec::new(),
6568 directives: Vec::new(),
6569 client_only_dynamic_import_spans: Vec::new(),
6570 security_sinks: Vec::new(),
6571 security_sinks_skipped: 0,
6572 security_unresolved_callee_sites: Vec::new(),
6573 tainted_bindings: Vec::new(),
6574 sanitized_sink_args: Vec::new(),
6575 security_control_sites: Vec::new(),
6576 callee_uses: Vec::new(),
6577 misplaced_directives: Vec::new(),
6578 inline_server_action_exports: Vec::new(),
6579 di_key_sites: Vec::new(),
6580 has_dynamic_provide: false,
6581 referenced_import_bindings: Vec::new(),
6582 component_props: Vec::new(),
6583 has_props_attrs_fallthrough: false,
6584 has_define_expose: false,
6585 has_define_model: false,
6586 has_unharvestable_props: false,
6587 component_emits: Vec::new(),
6588 angular_inputs: Vec::new(),
6589 angular_outputs: Vec::new(),
6590 has_unharvestable_emits: false,
6591 has_dynamic_emit: false,
6592 has_emit_whole_object_use: false,
6593 load_return_keys: Vec::new(),
6594 has_unharvestable_load: false,
6595 has_load_data_whole_use: false,
6596 has_page_data_store_whole_use: false,
6597 component_functions: Vec::new(),
6598 react_props: Vec::new(),
6599 hook_uses: Vec::new(),
6600 render_edges: Vec::new(),
6601 svelte_dispatched_events: Vec::new(),
6602 svelte_listened_events: Vec::new(),
6603 angular_component_selectors: Vec::new(),
6604 registered_custom_elements: Vec::new(),
6605 used_custom_element_tags: Vec::new(),
6606 angular_used_selectors: Vec::new(),
6607 angular_entry_component_refs: Vec::new(),
6608 has_dynamic_component_render: false,
6609 has_dynamic_dispatch: false,
6610 }
6611 }
6612
6613 fn make_fc(name: &str, cyclomatic: u16, cognitive: u16, line_count: u32) -> FunctionComplexity {
6614 FunctionComplexity {
6615 name: name.to_string(),
6616 line: 1,
6617 col: 0,
6618 cyclomatic,
6619 cognitive,
6620 line_count,
6621 param_count: 0,
6622 react_hook_count: 0,
6623 react_jsx_max_depth: 0,
6624 react_prop_count: 0,
6625 source_hash: None,
6626 contributions: Vec::new(),
6627 }
6628 }
6629
6630 fn make_fc_with_contributions(
6631 name: &str,
6632 cyclomatic: u16,
6633 cognitive: u16,
6634 ) -> FunctionComplexity {
6635 use fallow_types::extract::{
6636 ComplexityContribution, ComplexityContributionKind, ComplexityMetric,
6637 };
6638 let mut fc = make_fc(name, cyclomatic, cognitive, 50);
6639 fc.contributions = vec![ComplexityContribution {
6640 line: 2,
6641 col: 4,
6642 metric: ComplexityMetric::Cyclomatic,
6643 kind: ComplexityContributionKind::If,
6644 weight: 1,
6645 nesting: 0,
6646 }];
6647 fc
6648 }
6649
6650 #[test]
6651 fn collect_findings_omits_contributions_without_breakdown_flag() {
6652 let path = PathBuf::from("/project/src/a.ts");
6653 let modules = vec![make_module(
6654 FileId(0),
6655 vec![make_fc_with_contributions("complexFn", 25, 5)],
6656 )];
6657 let mut file_paths = FxHashMap::default();
6658 file_paths.insert(FileId(0), &path);
6659 let (findings, _, _) = collect_findings(
6660 &modules,
6661 &file_paths,
6662 Path::new("/project"),
6663 &globset::GlobSet::empty(),
6664 None,
6665 None,
6666 20,
6667 15,
6668 false,
6669 );
6670 assert_eq!(findings.len(), 1);
6671 assert!(
6672 findings[0].contributions.is_empty(),
6673 "contributions must be omitted without the breakdown flag"
6674 );
6675 }
6676
6677 #[test]
6678 fn collect_findings_includes_contributions_with_breakdown_flag() {
6679 let path = PathBuf::from("/project/src/a.ts");
6680 let modules = vec![make_module(
6681 FileId(0),
6682 vec![make_fc_with_contributions("complexFn", 25, 5)],
6683 )];
6684 let mut file_paths = FxHashMap::default();
6685 file_paths.insert(FileId(0), &path);
6686 let (findings, _, _) = collect_findings(
6687 &modules,
6688 &file_paths,
6689 Path::new("/project"),
6690 &globset::GlobSet::empty(),
6691 None,
6692 None,
6693 20,
6694 15,
6695 true,
6696 );
6697 assert_eq!(findings.len(), 1);
6698 assert_eq!(
6699 findings[0].contributions.len(),
6700 1,
6701 "contributions must flow through when the breakdown flag is set"
6702 );
6703 }
6704
6705 fn threshold_resolver(
6706 overrides: &[fallow_config::HealthThresholdOverride],
6707 ) -> ThresholdOverrideResolver {
6708 ThresholdOverrideResolver::new(
6709 overrides,
6710 GlobalHealthThresholds {
6711 cyclomatic: 20,
6712 cognitive: 15,
6713 crap: 30.0,
6714 },
6715 )
6716 }
6717
6718 #[test]
6719 fn collect_findings_uses_threshold_override_as_local_ceiling() {
6720 let path = PathBuf::from("/project/src/a.ts");
6721 let modules = vec![make_module(
6722 FileId(0),
6723 vec![make_fc("complexFn", 25, 20, 50)],
6724 )];
6725 let mut file_paths = FxHashMap::default();
6726 file_paths.insert(FileId(0), &path);
6727 let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
6728 files: vec!["src/a.ts".to_string()],
6729 functions: vec!["complexFn".to_string()],
6730 max_cyclomatic: Some(30),
6731 max_cognitive: Some(25),
6732 max_crap: None,
6733 reason: Some("approved assembly".to_string()),
6734 }]);
6735 let mut tracker = ThresholdOverrideStateTracker::default();
6736
6737 let mut input = CollectFindingsInput {
6738 modules: &modules,
6739 file_paths: &file_paths,
6740 config_root: Path::new("/project"),
6741 ignore_set: &globset::GlobSet::empty(),
6742 changed_files: None,
6743 ws_roots: None,
6744 threshold_resolver: &resolver,
6745 threshold_state_tracker: &mut tracker,
6746 complexity_breakdown: false,
6747 };
6748 let (findings, _, _) = collect_findings_with_resolver(&mut input);
6749
6750 assert!(findings.is_empty());
6751 let states = tracker.into_states();
6752 assert_eq!(states.len(), 1);
6753 assert!(matches!(
6754 states[0].status,
6755 fallow_output::ThresholdOverrideStatus::Active
6756 ));
6757 }
6758
6759 #[test]
6760 fn collect_findings_reports_when_local_ceiling_is_exceeded() {
6761 let path = PathBuf::from("/project/src/a.ts");
6762 let modules = vec![make_module(
6763 FileId(0),
6764 vec![make_fc("complexFn", 31, 20, 50)],
6765 )];
6766 let mut file_paths = FxHashMap::default();
6767 file_paths.insert(FileId(0), &path);
6768 let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
6769 files: vec!["src/a.ts".to_string()],
6770 functions: vec!["complexFn".to_string()],
6771 max_cyclomatic: Some(30),
6772 max_cognitive: Some(25),
6773 max_crap: None,
6774 reason: None,
6775 }]);
6776 let mut tracker = ThresholdOverrideStateTracker::default();
6777
6778 let mut input = CollectFindingsInput {
6779 modules: &modules,
6780 file_paths: &file_paths,
6781 config_root: Path::new("/project"),
6782 ignore_set: &globset::GlobSet::empty(),
6783 changed_files: None,
6784 ws_roots: None,
6785 threshold_resolver: &resolver,
6786 threshold_state_tracker: &mut tracker,
6787 complexity_breakdown: false,
6788 };
6789 let (findings, _, _) = collect_findings_with_resolver(&mut input);
6790
6791 assert_eq!(findings.len(), 1);
6792 assert_eq!(findings[0].effective_thresholds.unwrap().max_cyclomatic, 30);
6793 assert!(matches!(
6794 findings[0].threshold_source,
6795 Some(fallow_output::ThresholdSource::Override)
6796 ));
6797 }
6798
6799 #[test]
6800 fn collect_findings_reports_stale_override_when_under_global_thresholds() {
6801 let path = PathBuf::from("/project/src/a.ts");
6802 let modules = vec![make_module(
6803 FileId(0),
6804 vec![make_fc("complexFn", 10, 8, 20)],
6805 )];
6806 let mut file_paths = FxHashMap::default();
6807 file_paths.insert(FileId(0), &path);
6808 let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
6809 files: vec!["src/a.ts".to_string()],
6810 functions: vec!["complexFn".to_string()],
6811 max_cyclomatic: Some(30),
6812 max_cognitive: None,
6813 max_crap: None,
6814 reason: None,
6815 }]);
6816 let mut tracker = ThresholdOverrideStateTracker::default();
6817
6818 let mut input = CollectFindingsInput {
6819 modules: &modules,
6820 file_paths: &file_paths,
6821 config_root: Path::new("/project"),
6822 ignore_set: &globset::GlobSet::empty(),
6823 changed_files: None,
6824 ws_roots: None,
6825 threshold_resolver: &resolver,
6826 threshold_state_tracker: &mut tracker,
6827 complexity_breakdown: false,
6828 };
6829 let (findings, _, _) = collect_findings_with_resolver(&mut input);
6830
6831 assert!(findings.is_empty());
6832 let states = tracker.into_states();
6833 assert_eq!(states.len(), 1);
6834 assert!(matches!(
6835 states[0].status,
6836 fallow_output::ThresholdOverrideStatus::Stale
6837 ));
6838 }
6839
6840 #[test]
6841 fn threshold_override_tracker_reports_no_match_only_when_requested() {
6842 let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
6843 files: vec!["src/missing.ts".to_string()],
6844 functions: vec!["missingFn".to_string()],
6845 max_cyclomatic: Some(30),
6846 max_cognitive: None,
6847 max_crap: None,
6848 reason: None,
6849 }]);
6850 let mut tracker = ThresholdOverrideStateTracker::default();
6851 tracker.record_no_match_entries(&resolver, false);
6852 assert!(tracker.into_states().is_empty());
6853
6854 let mut tracker = ThresholdOverrideStateTracker::default();
6855 tracker.record_no_match_entries(&resolver, true);
6856 let states = tracker.into_states();
6857 assert_eq!(states.len(), 1);
6858 assert!(matches!(
6859 states[0].status,
6860 fallow_output::ThresholdOverrideStatus::NoMatch
6861 ));
6862 }
6863
6864 #[test]
6865 fn build_ignore_set_empty_patterns() {
6866 let set = build_ignore_set(&[]);
6867 assert!(set.is_empty());
6868 }
6869
6870 #[test]
6871 fn build_ignore_set_matches_glob() {
6872 let patterns = vec!["src/generated/**".to_string()];
6873 let set = build_ignore_set(&patterns);
6874 assert!(set.is_match(Path::new("src/generated/types.ts")));
6875 assert!(!set.is_match(Path::new("src/utils.ts")));
6876 }
6877
6878 #[test]
6879 fn build_ignore_set_multiple_patterns() {
6880 let patterns = vec!["*.test.ts".to_string(), "dist/**".to_string()];
6881 let set = build_ignore_set(&patterns);
6882 assert!(set.is_match(Path::new("foo.test.ts")));
6883 assert!(set.is_match(Path::new("dist/index.js")));
6884 assert!(!set.is_match(Path::new("src/index.ts")));
6885 }
6886
6887 #[test]
6888 #[should_panic(expected = "validated at config load time")]
6889 fn build_ignore_set_panics_on_unvalidated_invalid_pattern() {
6890 let patterns = vec!["[invalid".to_string(), "*.js".to_string()];
6891 let _ = build_ignore_set(&patterns);
6892 }
6893
6894 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
6895 ComplexityViolation {
6896 path: PathBuf::from("/project/src/a.ts"),
6897 name: name.to_string(),
6898 line: 1,
6899 col: 0,
6900 cyclomatic: match exceeded {
6901 ExceededThreshold::Cyclomatic
6902 | ExceededThreshold::Both
6903 | ExceededThreshold::CyclomaticCrap
6904 | ExceededThreshold::All => 25,
6905 _ => 8,
6906 },
6907 cognitive: match exceeded {
6908 ExceededThreshold::Cognitive
6909 | ExceededThreshold::Both
6910 | ExceededThreshold::CognitiveCrap
6911 | ExceededThreshold::All => 20,
6912 _ => 5,
6913 },
6914 line_count: 10,
6915 param_count: 0,
6916 react_hook_count: 0,
6917 react_jsx_max_depth: 0,
6918 react_prop_count: 0,
6919 react_hook_profile: None,
6920 exceeded,
6921 severity: FindingSeverity::Moderate,
6922 crap: exceeded.includes_crap().then_some(30.0),
6923 coverage_pct: None,
6924 coverage_tier: None,
6925 coverage_source: None,
6926 inherited_from: None,
6927 component_rollup: None,
6928 contributions: Vec::new(),
6929 effective_thresholds: None,
6930 threshold_source: None,
6931 }
6932 }
6933
6934 #[test]
6935 fn sort_findings_by_severity_surfaces_crap_before_single_metric_findings() {
6936 let mut findings = vec![
6937 make_finding("cyclomatic", ExceededThreshold::Cyclomatic),
6938 make_finding("cognitive", ExceededThreshold::Cognitive),
6939 make_finding("both", ExceededThreshold::Both),
6940 make_finding("crap", ExceededThreshold::Crap),
6941 make_finding("cyclomatic_crap", ExceededThreshold::CyclomaticCrap),
6942 make_finding("all", ExceededThreshold::All),
6943 ];
6944
6945 sort_findings(&mut findings, HealthSort::Severity);
6946
6947 let names = findings
6948 .iter()
6949 .map(|finding| finding.name.as_str())
6950 .collect::<Vec<_>>();
6951 assert_eq!(
6952 names,
6953 [
6954 "all",
6955 "cyclomatic_crap",
6956 "crap",
6957 "both",
6958 "cyclomatic",
6959 "cognitive",
6960 ]
6961 );
6962 }
6963
6964 #[test]
6965 fn collect_findings_empty_modules() {
6966 let (findings, files, functions) = collect_findings(
6967 &[],
6968 &FxHashMap::default(),
6969 Path::new("/project"),
6970 &globset::GlobSet::empty(),
6971 None,
6972 None,
6973 20,
6974 15,
6975 false,
6976 );
6977 assert!(findings.is_empty());
6978 assert_eq!(files, 0);
6979 assert_eq!(functions, 0);
6980 }
6981
6982 #[test]
6983 fn collect_findings_below_threshold() {
6984 let path = PathBuf::from("/project/src/a.ts");
6985 let modules = vec![make_module(FileId(0), vec![make_fc("doStuff", 5, 3, 10)])];
6986 let mut file_paths = FxHashMap::default();
6987 file_paths.insert(FileId(0), &path);
6988
6989 let (findings, files, functions) = collect_findings(
6990 &modules,
6991 &file_paths,
6992 Path::new("/project"),
6993 &globset::GlobSet::empty(),
6994 None,
6995 None,
6996 20,
6997 15,
6998 false,
6999 );
7000 assert!(findings.is_empty());
7001 assert_eq!(files, 1);
7002 assert_eq!(functions, 1);
7003 }
7004
7005 #[test]
7006 fn collect_findings_exceeds_cyclomatic_only() {
7007 let path = PathBuf::from("/project/src/a.ts");
7008 let modules = vec![make_module(
7009 FileId(0),
7010 vec![make_fc("complexFn", 25, 5, 50)],
7011 )];
7012 let mut file_paths = FxHashMap::default();
7013 file_paths.insert(FileId(0), &path);
7014
7015 let (findings, _, _) = collect_findings(
7016 &modules,
7017 &file_paths,
7018 Path::new("/project"),
7019 &globset::GlobSet::empty(),
7020 None,
7021 None,
7022 20,
7023 15,
7024 false,
7025 );
7026 assert_eq!(findings.len(), 1);
7027 assert_eq!(findings[0].cyclomatic, 25);
7028 assert!(matches!(
7029 findings[0].exceeded,
7030 ExceededThreshold::Cyclomatic
7031 ));
7032 }
7033
7034 #[test]
7035 fn collect_findings_exceeds_cognitive_only() {
7036 let path = PathBuf::from("/project/src/a.ts");
7037 let modules = vec![make_module(FileId(0), vec![make_fc("nestedFn", 5, 20, 30)])];
7038 let mut file_paths = FxHashMap::default();
7039 file_paths.insert(FileId(0), &path);
7040
7041 let (findings, _, _) = collect_findings(
7042 &modules,
7043 &file_paths,
7044 Path::new("/project"),
7045 &globset::GlobSet::empty(),
7046 None,
7047 None,
7048 20,
7049 15,
7050 false,
7051 );
7052 assert_eq!(findings.len(), 1);
7053 assert!(matches!(findings[0].exceeded, ExceededThreshold::Cognitive));
7054 }
7055
7056 #[test]
7057 fn collect_findings_exceeds_both() {
7058 let path = PathBuf::from("/project/src/a.ts");
7059 let modules = vec![make_module(
7060 FileId(0),
7061 vec![make_fc("terribleFn", 25, 20, 100)],
7062 )];
7063 let mut file_paths = FxHashMap::default();
7064 file_paths.insert(FileId(0), &path);
7065
7066 let (findings, _, _) = collect_findings(
7067 &modules,
7068 &file_paths,
7069 Path::new("/project"),
7070 &globset::GlobSet::empty(),
7071 None,
7072 None,
7073 20,
7074 15,
7075 false,
7076 );
7077 assert_eq!(findings.len(), 1);
7078 assert!(matches!(findings[0].exceeded, ExceededThreshold::Both));
7079 }
7080
7081 #[test]
7082 fn collect_findings_multiple_functions_per_file() {
7083 let path = PathBuf::from("/project/src/a.ts");
7084 let modules = vec![make_module(
7085 FileId(0),
7086 vec![
7087 make_fc("ok", 5, 3, 10),
7088 make_fc("bad", 25, 20, 50),
7089 make_fc("also_bad", 21, 5, 30),
7090 ],
7091 )];
7092 let mut file_paths = FxHashMap::default();
7093 file_paths.insert(FileId(0), &path);
7094
7095 let (findings, files, functions) = collect_findings(
7096 &modules,
7097 &file_paths,
7098 Path::new("/project"),
7099 &globset::GlobSet::empty(),
7100 None,
7101 None,
7102 20,
7103 15,
7104 false,
7105 );
7106 assert_eq!(findings.len(), 2);
7107 assert_eq!(files, 1);
7108 assert_eq!(functions, 3);
7109 }
7110
7111 #[test]
7112 fn collect_findings_ignores_matching_files() {
7113 let path = PathBuf::from("/project/src/generated/types.ts");
7114 let modules = vec![make_module(FileId(0), vec![make_fc("genFn", 25, 20, 50)])];
7115 let mut file_paths = FxHashMap::default();
7116 file_paths.insert(FileId(0), &path);
7117
7118 let ignore_set = build_ignore_set(&["src/generated/**".to_string()]);
7119 let (findings, files, _) = collect_findings(
7120 &modules,
7121 &file_paths,
7122 Path::new("/project"),
7123 &ignore_set,
7124 None,
7125 None,
7126 20,
7127 15,
7128 false,
7129 );
7130 assert!(findings.is_empty());
7131 assert_eq!(files, 0);
7132 }
7133
7134 #[test]
7135 fn collect_findings_filters_by_changed_files() {
7136 let path_a = PathBuf::from("/project/src/a.ts");
7137 let path_b = PathBuf::from("/project/src/b.ts");
7138 let modules = vec![
7139 make_module(FileId(0), vec![make_fc("fnA", 25, 20, 50)]),
7140 make_module(FileId(1), vec![make_fc("fnB", 25, 20, 50)]),
7141 ];
7142 let mut file_paths = FxHashMap::default();
7143 file_paths.insert(FileId(0), &path_a);
7144 file_paths.insert(FileId(1), &path_b);
7145
7146 let mut changed = FxHashSet::default();
7147 changed.insert(PathBuf::from("/project/src/a.ts"));
7148
7149 let (findings, files, _) = collect_findings(
7150 &modules,
7151 &file_paths,
7152 Path::new("/project"),
7153 &globset::GlobSet::empty(),
7154 Some(&changed),
7155 None,
7156 20,
7157 15,
7158 false,
7159 );
7160 assert_eq!(findings.len(), 1);
7161 assert_eq!(findings[0].name, "fnA");
7162 assert_eq!(files, 1);
7163 }
7164
7165 fn build_diff(text: &str) -> fallow_output::DiffIndex {
7166 fallow_output::DiffIndex::from_unified_diff(text)
7167 }
7168
7169 #[test]
7170 fn filter_complexity_findings_by_diff_keeps_hotspot_overlapping_diff_line() {
7171 let mut findings = vec![ComplexityViolation {
7172 path: PathBuf::from("/project/src/big.ts"),
7173 name: "wide_fn".into(),
7174 line: 10,
7175 col: 0,
7176 cyclomatic: 30,
7177 cognitive: 30,
7178 line_count: 110,
7179 param_count: 0,
7180 react_hook_count: 0,
7181 react_jsx_max_depth: 0,
7182 react_prop_count: 0,
7183 react_hook_profile: None,
7184 exceeded: ExceededThreshold::Both,
7185 severity: FindingSeverity::High,
7186 crap: None,
7187 coverage_pct: None,
7188 coverage_tier: None,
7189 coverage_source: None,
7190 inherited_from: None,
7191 component_rollup: None,
7192 contributions: Vec::new(),
7193 effective_thresholds: None,
7194 threshold_source: None,
7195 }];
7196 let diff = build_diff(
7197 "diff --git a/src/big.ts b/src/big.ts\n\
7198 --- a/src/big.ts\n\
7199 +++ b/src/big.ts\n\
7200 @@ -114,1 +114,2 @@\n\
7201 ctx\n\
7202 +touched\n",
7203 );
7204 filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
7205 assert_eq!(findings.len(), 1);
7206 }
7207
7208 #[test]
7209 fn filter_complexity_findings_by_diff_drops_finding_outside_diff() {
7210 let mut findings = vec![ComplexityViolation {
7211 path: PathBuf::from("/project/src/elsewhere.ts"),
7212 name: "outside".into(),
7213 line: 10,
7214 col: 0,
7215 cyclomatic: 30,
7216 cognitive: 30,
7217 line_count: 5,
7218 param_count: 0,
7219 react_hook_count: 0,
7220 react_jsx_max_depth: 0,
7221 react_prop_count: 0,
7222 react_hook_profile: None,
7223 exceeded: ExceededThreshold::Both,
7224 severity: FindingSeverity::High,
7225 crap: None,
7226 coverage_pct: None,
7227 coverage_tier: None,
7228 coverage_source: None,
7229 inherited_from: None,
7230 component_rollup: None,
7231 contributions: Vec::new(),
7232 effective_thresholds: None,
7233 threshold_source: None,
7234 }];
7235 let diff = build_diff(
7236 "diff --git a/src/big.ts b/src/big.ts\n\
7237 --- a/src/big.ts\n\
7238 +++ b/src/big.ts\n\
7239 @@ -114,1 +114,2 @@\n\
7240 ctx\n\
7241 +touched\n",
7242 );
7243 filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
7244 assert!(findings.is_empty());
7245 }
7246
7247 #[test]
7248 fn filter_complexity_findings_by_diff_handles_zero_line_count() {
7249 let mut findings = vec![ComplexityViolation {
7250 path: PathBuf::from("/project/src/a.ts"),
7251 name: "zero_extent".into(),
7252 line: 5,
7253 col: 0,
7254 cyclomatic: 30,
7255 cognitive: 30,
7256 line_count: 0,
7257 param_count: 0,
7258 react_hook_count: 0,
7259 react_jsx_max_depth: 0,
7260 react_prop_count: 0,
7261 react_hook_profile: None,
7262 exceeded: ExceededThreshold::Both,
7263 severity: FindingSeverity::High,
7264 crap: None,
7265 coverage_pct: None,
7266 coverage_tier: None,
7267 coverage_source: None,
7268 inherited_from: None,
7269 component_rollup: None,
7270 contributions: Vec::new(),
7271 effective_thresholds: None,
7272 threshold_source: None,
7273 }];
7274 let diff = build_diff(
7275 "diff --git a/src/a.ts b/src/a.ts\n\
7276 --- a/src/a.ts\n\
7277 +++ b/src/a.ts\n\
7278 @@ -4,1 +4,2 @@\n\
7279 ctx\n\
7280 +touched\n",
7281 );
7282 filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
7283 assert_eq!(findings.len(), 1);
7284 }
7285
7286 #[test]
7287 fn filter_hotspots_by_diff_uses_file_level_membership() {
7288 use fallow_output::HotspotEntry;
7289 let mut hotspots = vec![
7290 HotspotEntry {
7291 path: PathBuf::from("/project/src/touched.ts"),
7292 score: 90.0,
7293 commits: 50,
7294 weighted_commits: 25.0,
7295 lines_added: 1000,
7296 lines_deleted: 500,
7297 complexity_density: 0.4,
7298 fan_in: 5,
7299 trend: crate::churn::ChurnTrend::Stable,
7300 ownership: None,
7301 is_test_path: false,
7302 },
7303 HotspotEntry {
7304 path: PathBuf::from("/project/src/untouched.ts"),
7305 score: 90.0,
7306 commits: 50,
7307 weighted_commits: 25.0,
7308 lines_added: 1000,
7309 lines_deleted: 500,
7310 complexity_density: 0.4,
7311 fan_in: 5,
7312 trend: crate::churn::ChurnTrend::Stable,
7313 ownership: None,
7314 is_test_path: false,
7315 },
7316 ];
7317 let diff = build_diff(
7318 "diff --git a/src/touched.ts b/src/touched.ts\n\
7319 --- a/src/touched.ts\n\
7320 +++ b/src/touched.ts\n\
7321 @@ -0,0 +1,1 @@\n\
7322 +new\n",
7323 );
7324 filter_hotspots_by_diff(&mut hotspots, &diff, Path::new("/project"));
7325 assert_eq!(hotspots.len(), 1);
7326 assert_eq!(hotspots[0].path, PathBuf::from("/project/src/touched.ts"));
7327 }
7328
7329 #[test]
7330 fn filter_large_functions_by_diff_uses_range_overlap() {
7331 use fallow_output::LargeFunctionEntry;
7332 let mut entries = vec![
7333 LargeFunctionEntry {
7334 path: PathBuf::from("/project/src/a.ts"),
7335 name: "kept".into(),
7336 line: 10,
7337 line_count: 100,
7338 },
7339 LargeFunctionEntry {
7340 path: PathBuf::from("/project/src/a.ts"),
7341 name: "dropped".into(),
7342 line: 500,
7343 line_count: 100,
7344 },
7345 ];
7346 let diff = build_diff(
7347 "diff --git a/src/a.ts b/src/a.ts\n\
7348 --- a/src/a.ts\n\
7349 +++ b/src/a.ts\n\
7350 @@ -49,1 +49,2 @@\n\
7351 ctx\n\
7352 +touched\n",
7353 );
7354 filter_large_functions_by_diff(&mut entries, &diff, Path::new("/project"));
7355 assert_eq!(entries.len(), 1);
7356 assert_eq!(entries[0].name, "kept");
7357 }
7358
7359 #[test]
7360 fn collect_findings_skips_module_without_path() {
7361 let modules = vec![make_module(FileId(99), vec![make_fc("orphan", 25, 20, 50)])];
7362 let file_paths = FxHashMap::default();
7363
7364 let (findings, files, _) = collect_findings(
7365 &modules,
7366 &file_paths,
7367 Path::new("/project"),
7368 &globset::GlobSet::empty(),
7369 None,
7370 None,
7371 20,
7372 15,
7373 false,
7374 );
7375 assert!(findings.is_empty());
7376 assert_eq!(files, 0);
7377 }
7378
7379 #[test]
7380 fn collect_findings_at_exact_threshold_not_reported() {
7381 let path = PathBuf::from("/project/src/a.ts");
7382 let modules = vec![make_module(
7383 FileId(0),
7384 vec![make_fc("borderline", 20, 15, 20)],
7385 )];
7386 let mut file_paths = FxHashMap::default();
7387 file_paths.insert(FileId(0), &path);
7388
7389 let (findings, _, _) = collect_findings(
7390 &modules,
7391 &file_paths,
7392 Path::new("/project"),
7393 &globset::GlobSet::empty(),
7394 None,
7395 None,
7396 20,
7397 15,
7398 false,
7399 );
7400 assert!(findings.is_empty());
7401 }
7402
7403 #[test]
7404 fn collect_findings_preserves_function_metadata() {
7405 let path = PathBuf::from("/project/src/a.ts");
7406 let modules = vec![make_module(
7407 FileId(0),
7408 vec![FunctionComplexity {
7409 name: "processData".to_string(),
7410 line: 42,
7411 col: 8,
7412 cyclomatic: 25,
7413 cognitive: 18,
7414 line_count: 75,
7415 param_count: 2,
7416 react_hook_count: 0,
7417 react_jsx_max_depth: 0,
7418 react_prop_count: 0,
7419 source_hash: None,
7420 contributions: Vec::new(),
7421 }],
7422 )];
7423 let mut file_paths = FxHashMap::default();
7424 file_paths.insert(FileId(0), &path);
7425
7426 let (findings, _, _) = collect_findings(
7427 &modules,
7428 &file_paths,
7429 Path::new("/project"),
7430 &globset::GlobSet::empty(),
7431 None,
7432 None,
7433 20,
7434 15,
7435 false,
7436 );
7437 assert_eq!(findings.len(), 1);
7438 let f = &findings[0];
7439 assert_eq!(f.name, "processData");
7440 assert_eq!(f.line, 42);
7441 assert_eq!(f.col, 8);
7442 assert_eq!(f.cyclomatic, 25);
7443 assert_eq!(f.cognitive, 18);
7444 assert_eq!(f.line_count, 75);
7445 assert_eq!(f.path, PathBuf::from("/project/src/a.ts"));
7446 }
7447
7448 #[test]
7449 fn merge_crap_findings_disambiguates_same_line_functions() {
7450 let path = PathBuf::from("/project/src/curried.ts");
7451 let outer = FunctionComplexity {
7452 name: "handler".to_string(),
7453 line: 1,
7454 col: 23,
7455 cyclomatic: 1,
7456 cognitive: 0,
7457 line_count: 11,
7458 param_count: 1,
7459 react_hook_count: 0,
7460 react_jsx_max_depth: 0,
7461 react_prop_count: 0,
7462 source_hash: None,
7463 contributions: Vec::new(),
7464 };
7465 let inner = FunctionComplexity {
7466 name: "<arrow>".to_string(),
7467 line: 1,
7468 col: 43,
7469 cyclomatic: 7,
7470 cognitive: 0,
7471 line_count: 10,
7472 param_count: 1,
7473 react_hook_count: 0,
7474 react_jsx_max_depth: 0,
7475 react_prop_count: 0,
7476 source_hash: None,
7477 contributions: Vec::new(),
7478 };
7479 let modules = vec![make_module(FileId(0), vec![inner.clone(), outer.clone()])];
7480 let mut file_paths: FxHashMap<FileId, &PathBuf> = FxHashMap::default();
7481 file_paths.insert(FileId(0), &path);
7482
7483 let mut findings: Vec<ComplexityViolation> = Vec::new();
7484
7485 let mut per_function_crap: FxHashMap<PathBuf, Vec<scoring::PerFunctionCrap>> =
7486 FxHashMap::default();
7487 per_function_crap.insert(
7488 path.clone(),
7489 vec![
7490 scoring::PerFunctionCrap {
7491 line: inner.line,
7492 col: inner.col,
7493 crap: 56.0,
7494 coverage_pct: None,
7495 coverage_tier: fallow_output::CoverageTier::None,
7496 coverage_source: fallow_output::CoverageSource::Estimated,
7497 },
7498 scoring::PerFunctionCrap {
7499 line: outer.line,
7500 col: outer.col,
7501 crap: 2.0,
7502 coverage_pct: None,
7503 coverage_tier: fallow_output::CoverageTier::None,
7504 coverage_source: fallow_output::CoverageSource::Estimated,
7505 },
7506 ],
7507 );
7508
7509 let resolver = threshold_resolver(&[]);
7510 let mut tracker = ThresholdOverrideStateTracker::default();
7511 let mut input = CrapFindingMergeInput {
7512 modules: &modules,
7513 file_paths: &file_paths,
7514 config_root: Path::new("/project"),
7515 ignore_set: &globset::GlobSet::empty(),
7516 changed_files: None,
7517 ws_roots: None,
7518 per_function_crap: &per_function_crap,
7519 template_inherit_provenance: &FxHashMap::default(),
7520 complexity_breakdown: false,
7521 threshold_resolver: &resolver,
7522 threshold_state_tracker: &mut tracker,
7523 };
7524 merge_crap_findings(&mut findings, &mut input);
7525
7526 assert_eq!(
7527 findings.len(),
7528 1,
7529 "expected one CRAP finding for inner arrow"
7530 );
7531 let f = &findings[0];
7532 assert_eq!(f.name, "<arrow>", "name must come from inner arrow");
7533 assert_eq!(f.line, 1);
7534 assert_eq!(f.col, 43, "col must disambiguate same-line arrows");
7535 assert_eq!(f.cyclomatic, 7, "cyclomatic must come from inner arrow");
7536 assert_eq!(f.cognitive, 0);
7537 assert_eq!(
7538 f.crap,
7539 Some(56.0),
7540 "CRAP must match the function it's reported against"
7541 );
7542 let cc = f64::from(f.cyclomatic);
7543 #[expect(
7544 clippy::suboptimal_flops,
7545 reason = "cc * cc + cc matches the CRAP formula specification"
7546 )]
7547 let expected_crap = cc * cc + cc;
7548 assert!(
7549 (f.crap.unwrap() - expected_crap).abs() < 0.01,
7550 "CRAP must be consistent with reported CC: cc={cc}, crap={:?}, expected={expected_crap}",
7551 f.crap,
7552 );
7553 }
7554
7555 #[test]
7556 fn merge_crap_findings_picks_outer_when_outer_exceeds() {
7557 let path = PathBuf::from("/project/src/curried_outer.ts");
7558 let outer = FunctionComplexity {
7559 name: "complex".to_string(),
7560 line: 5,
7561 col: 10,
7562 cyclomatic: 8,
7563 cognitive: 0,
7564 line_count: 20,
7565 param_count: 1,
7566 react_hook_count: 0,
7567 react_jsx_max_depth: 0,
7568 react_prop_count: 0,
7569 source_hash: None,
7570 contributions: Vec::new(),
7571 };
7572 let inner = FunctionComplexity {
7573 name: "<arrow>".to_string(),
7574 line: 5,
7575 col: 30,
7576 cyclomatic: 1,
7577 cognitive: 0,
7578 line_count: 1,
7579 param_count: 1,
7580 react_hook_count: 0,
7581 react_jsx_max_depth: 0,
7582 react_prop_count: 0,
7583 source_hash: None,
7584 contributions: Vec::new(),
7585 };
7586 let modules = vec![make_module(FileId(0), vec![inner.clone(), outer.clone()])];
7587 let mut file_paths: FxHashMap<FileId, &PathBuf> = FxHashMap::default();
7588 file_paths.insert(FileId(0), &path);
7589
7590 let mut findings: Vec<ComplexityViolation> = Vec::new();
7591 let mut per_function_crap: FxHashMap<PathBuf, Vec<scoring::PerFunctionCrap>> =
7592 FxHashMap::default();
7593 per_function_crap.insert(
7594 path.clone(),
7595 vec![
7596 scoring::PerFunctionCrap {
7597 line: inner.line,
7598 col: inner.col,
7599 crap: 2.0,
7600 coverage_pct: None,
7601 coverage_tier: fallow_output::CoverageTier::None,
7602 coverage_source: fallow_output::CoverageSource::Estimated,
7603 },
7604 scoring::PerFunctionCrap {
7605 line: outer.line,
7606 col: outer.col,
7607 crap: 72.0,
7608 coverage_pct: None,
7609 coverage_tier: fallow_output::CoverageTier::None,
7610 coverage_source: fallow_output::CoverageSource::Estimated,
7611 },
7612 ],
7613 );
7614
7615 let resolver = threshold_resolver(&[]);
7616 let mut tracker = ThresholdOverrideStateTracker::default();
7617 let mut input = CrapFindingMergeInput {
7618 modules: &modules,
7619 file_paths: &file_paths,
7620 config_root: Path::new("/project"),
7621 ignore_set: &globset::GlobSet::empty(),
7622 changed_files: None,
7623 ws_roots: None,
7624 per_function_crap: &per_function_crap,
7625 template_inherit_provenance: &FxHashMap::default(),
7626 complexity_breakdown: false,
7627 threshold_resolver: &resolver,
7628 threshold_state_tracker: &mut tracker,
7629 };
7630 merge_crap_findings(&mut findings, &mut input);
7631
7632 assert_eq!(findings.len(), 1);
7633 let f = &findings[0];
7634 assert_eq!(f.name, "complex");
7635 assert_eq!(f.col, 10);
7636 assert_eq!(f.cyclomatic, 8);
7637 assert_eq!(f.crap, Some(72.0));
7638 }
7639
7640 fn fx_summary(
7641 tracked: usize,
7642 hit: usize,
7643 unhit: usize,
7644 untracked: usize,
7645 ) -> fallow_output::RuntimeCoverageSummary {
7646 #[expect(
7647 clippy::cast_precision_loss,
7648 reason = "test fixture totals are tiny, f64 precision is fine"
7649 )]
7650 let coverage_percent = if tracked == 0 {
7651 0.0
7652 } else {
7653 (hit as f64 / tracked as f64) * 100.0
7654 };
7655 fallow_output::RuntimeCoverageSummary {
7656 data_source: fallow_output::RuntimeCoverageDataSource::Local,
7657 last_received_at: None,
7658 functions_tracked: tracked,
7659 functions_hit: hit,
7660 functions_unhit: unhit,
7661 functions_untracked: untracked,
7662 coverage_percent,
7663 trace_count: 512,
7664 period_days: 7,
7665 deployments_seen: 2,
7666 capture_quality: None,
7667 }
7668 }
7669
7670 fn fx_evidence(
7671 static_status: &str,
7672 test_coverage: &str,
7673 v8_tracking: &str,
7674 ) -> fallow_output::RuntimeCoverageEvidence {
7675 fallow_output::RuntimeCoverageEvidence {
7676 static_status: static_status.to_owned(),
7677 test_coverage: test_coverage.to_owned(),
7678 v8_tracking: v8_tracking.to_owned(),
7679 untracked_reason: None,
7680 observation_days: 7,
7681 deployments_observed: 2,
7682 }
7683 }
7684
7685 #[test]
7686 #[expect(
7687 clippy::too_many_lines,
7688 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
7689 )]
7690 fn runtime_coverage_top_applies_after_baseline_filtering() {
7691 let root = Path::new("/project");
7692 let baseline = HealthBaselineData {
7693 findings: vec![],
7694 finding_counts: std::collections::BTreeMap::new(),
7695 runtime_coverage_findings: vec![
7696 "fallow:prod:aaaaaaaa".to_owned(),
7697 "fallow:prod:bbbbbbbb".to_owned(),
7698 ],
7699 runtime_coverage_source_hashes: vec![],
7700 target_keys: vec![],
7701 };
7702 let mut report = fallow_output::RuntimeCoverageReport {
7703 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7704 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
7705 signals: Vec::new(),
7706 summary: fx_summary(3, 0, 2, 1),
7707 findings: vec![
7708 fallow_output::RuntimeCoverageFinding {
7709 id: "fallow:prod:aaaaaaaa".to_owned(),
7710 stable_id: None,
7711 path: PathBuf::from("/project/src/a.ts"),
7712 function: "alpha".to_owned(),
7713 line: 10,
7714 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
7715 invocations: Some(0),
7716 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
7717 evidence: fx_evidence("used", "not_covered", "tracked"),
7718 actions: vec![],
7719 source_hash: None,
7720 discriminators: None,
7721 },
7722 fallow_output::RuntimeCoverageFinding {
7723 id: "fallow:prod:bbbbbbbb".to_owned(),
7724 stable_id: None,
7725 path: PathBuf::from("/project/src/b.ts"),
7726 function: "beta".to_owned(),
7727 line: 20,
7728 verdict: fallow_output::RuntimeCoverageVerdict::CoverageUnavailable,
7729 invocations: None,
7730 confidence: fallow_output::RuntimeCoverageConfidence::None,
7731 evidence: fx_evidence("used", "not_covered", "untracked"),
7732 actions: vec![],
7733 source_hash: None,
7734 discriminators: None,
7735 },
7736 fallow_output::RuntimeCoverageFinding {
7737 id: "fallow:prod:cccccccc".to_owned(),
7738 stable_id: None,
7739 path: PathBuf::from("/project/src/c.ts"),
7740 function: "gamma".to_owned(),
7741 line: 30,
7742 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
7743 invocations: Some(0),
7744 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
7745 evidence: fx_evidence("used", "not_covered", "tracked"),
7746 actions: vec![],
7747 source_hash: None,
7748 discriminators: None,
7749 },
7750 ],
7751 hot_paths: vec![
7752 fallow_output::RuntimeCoverageHotPath {
7753 id: "fallow:hot:11111111".to_owned(),
7754 stable_id: None,
7755 path: PathBuf::from("/project/src/hot-a.ts"),
7756 function: "hotAlpha".to_owned(),
7757 line: 1,
7758 end_line: 5,
7759 invocations: 500,
7760 percentile: 99,
7761 actions: vec![],
7762 },
7763 fallow_output::RuntimeCoverageHotPath {
7764 id: "fallow:hot:22222222".to_owned(),
7765 stable_id: None,
7766 path: PathBuf::from("/project/src/hot-b.ts"),
7767 function: "hotBeta".to_owned(),
7768 line: 2,
7769 end_line: 8,
7770 invocations: 250,
7771 percentile: 50,
7772 actions: vec![],
7773 },
7774 ],
7775 blast_radius: vec![],
7776 importance: vec![],
7777 watermark: None,
7778 warnings: vec![],
7779 actionable: true,
7780 actionability_reason: None,
7781 actionability_verdict: None,
7782 provenance: fallow_output::RuntimeCoverageProvenance::default(),
7783 };
7784
7785 apply_runtime_coverage_filters(
7786 &mut report,
7787 &RuntimeCoverageFilterContext::new(root)
7788 .with_baseline(Some(&baseline))
7789 .with_top(Some(1)),
7790 );
7791
7792 assert_eq!(report.findings.len(), 1);
7793 assert_eq!(report.findings[0].function, "gamma");
7794 assert_eq!(
7795 report.verdict,
7796 fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected
7797 );
7798 assert_eq!(report.summary.functions_tracked, 3);
7799 assert_eq!(report.summary.functions_hit, 0);
7800 assert_eq!(report.summary.functions_unhit, 2);
7801 assert_eq!(report.summary.functions_untracked, 1);
7802 assert!((report.summary.coverage_percent - 0.0).abs() < 0.05);
7803 assert_eq!(report.hot_paths.len(), 1);
7804 assert_eq!(report.hot_paths[0].function, "hotAlpha");
7805 }
7806
7807 #[test]
7808 fn runtime_coverage_baseline_refreshes_to_clean_when_only_baselined_findings_remain() {
7809 let root = Path::new("/project");
7810 let baseline = HealthBaselineData {
7811 findings: vec![],
7812 finding_counts: std::collections::BTreeMap::new(),
7813 runtime_coverage_findings: vec!["fallow:prod:aaaaaaaa".to_owned()],
7814 runtime_coverage_source_hashes: vec![],
7815 target_keys: vec![],
7816 };
7817 let mut report = fallow_output::RuntimeCoverageReport {
7818 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7819 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
7820 signals: Vec::new(),
7821 summary: fx_summary(2, 1, 1, 0),
7822 findings: vec![fallow_output::RuntimeCoverageFinding {
7823 id: "fallow:prod:aaaaaaaa".to_owned(),
7824 stable_id: None,
7825 path: PathBuf::from("/project/src/a.ts"),
7826 function: "alpha".to_owned(),
7827 line: 10,
7828 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
7829 invocations: Some(0),
7830 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
7831 evidence: fx_evidence("used", "not_covered", "tracked"),
7832 actions: vec![],
7833 source_hash: None,
7834 discriminators: None,
7835 }],
7836 hot_paths: vec![],
7837 blast_radius: vec![],
7838 importance: vec![],
7839 watermark: None,
7840 warnings: vec![],
7841 actionable: true,
7842 actionability_reason: None,
7843 actionability_verdict: None,
7844 provenance: fallow_output::RuntimeCoverageProvenance::default(),
7845 };
7846
7847 apply_runtime_coverage_filters(
7848 &mut report,
7849 &RuntimeCoverageFilterContext::new(root).with_baseline(Some(&baseline)),
7850 );
7851
7852 assert!(report.findings.is_empty());
7853 assert_eq!(
7854 report.verdict,
7855 fallow_output::RuntimeCoverageReportVerdict::Clean
7856 );
7857 assert_eq!(report.summary.functions_tracked, 2);
7858 assert_eq!(report.summary.functions_hit, 1);
7859 assert_eq!(report.summary.functions_unhit, 1);
7860 assert_eq!(report.summary.functions_untracked, 0);
7861 assert!((report.summary.coverage_percent - 50.0).abs() < 0.05);
7862 }
7863
7864 #[test]
7865 fn runtime_coverage_changed_review_uses_hot_path_verdict() {
7866 let root = Path::new("/project");
7867 let mut changed_files = FxHashSet::default();
7868 changed_files.insert(PathBuf::from("/project/src/hot.ts"));
7869 let mut report = fallow_output::RuntimeCoverageReport {
7870 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7871 verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
7872 signals: Vec::new(),
7873 summary: fx_summary(2, 2, 0, 0),
7874 findings: vec![],
7875 hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
7876 id: "fallow:hot:33333333".to_owned(),
7877 stable_id: None,
7878 path: PathBuf::from("/project/src/hot.ts"),
7879 function: "renderHotPath".to_owned(),
7880 line: 7,
7881 end_line: 24,
7882 invocations: 9_500,
7883 percentile: 99,
7884 actions: vec![],
7885 }],
7886 blast_radius: vec![],
7887 importance: vec![],
7888 watermark: None,
7889 warnings: vec![],
7890 actionable: true,
7891 actionability_reason: None,
7892 actionability_verdict: None,
7893 provenance: fallow_output::RuntimeCoverageProvenance::default(),
7894 };
7895
7896 apply_runtime_coverage_filters(
7897 &mut report,
7898 &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
7899 );
7900
7901 assert_eq!(
7902 report.verdict,
7903 fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
7904 );
7905 }
7906
7907 #[test]
7908 fn runtime_coverage_changed_review_ignores_unmodified_hot_paths() {
7909 let root = Path::new("/project");
7910 let mut changed_files = FxHashSet::default();
7911 changed_files.insert(PathBuf::from("/project/src/other.ts"));
7912 let mut report = fallow_output::RuntimeCoverageReport {
7913 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7914 verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
7915 signals: Vec::new(),
7916 summary: fx_summary(2, 2, 0, 0),
7917 findings: vec![],
7918 hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
7919 id: "fallow:hot:44444444".to_owned(),
7920 stable_id: None,
7921 path: PathBuf::from("/project/src/hot.ts"),
7922 function: "renderHotPath".to_owned(),
7923 line: 7,
7924 end_line: 24,
7925 invocations: 9_500,
7926 percentile: 90,
7927 actions: vec![],
7928 }],
7929 blast_radius: vec![],
7930 importance: vec![],
7931 watermark: None,
7932 warnings: vec![],
7933 actionable: true,
7934 actionability_reason: None,
7935 actionability_verdict: None,
7936 provenance: fallow_output::RuntimeCoverageProvenance::default(),
7937 };
7938
7939 apply_runtime_coverage_filters(
7940 &mut report,
7941 &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
7942 );
7943
7944 assert!(report.hot_paths.is_empty());
7945 assert_eq!(
7946 report.verdict,
7947 fallow_output::RuntimeCoverageReportVerdict::Clean
7948 );
7949 }
7950
7951 fn fx_runtime_coverage_report_with_hot_paths(
7952 hot_paths: Vec<fallow_output::RuntimeCoverageHotPath>,
7953 ) -> fallow_output::RuntimeCoverageReport {
7954 fallow_output::RuntimeCoverageReport {
7955 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7956 verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
7957 signals: Vec::new(),
7958 summary: fx_summary(2, 2, 0, 0),
7959 findings: vec![],
7960 hot_paths,
7961 blast_radius: vec![],
7962 importance: vec![],
7963 watermark: None,
7964 warnings: vec![],
7965 actionable: true,
7966 actionability_reason: None,
7967 actionability_verdict: None,
7968 provenance: fallow_output::RuntimeCoverageProvenance::default(),
7969 }
7970 }
7971
7972 fn fx_hot_path(
7973 id: &str,
7974 path: &str,
7975 line: u32,
7976 end_line: u32,
7977 ) -> fallow_output::RuntimeCoverageHotPath {
7978 fallow_output::RuntimeCoverageHotPath {
7979 id: id.to_owned(),
7980 stable_id: None,
7981 path: PathBuf::from(path),
7982 function: "renderHotPath".to_owned(),
7983 line,
7984 end_line,
7985 invocations: 9_500,
7986 percentile: 99,
7987 actions: vec![],
7988 }
7989 }
7990
7991 #[test]
7992 fn runtime_coverage_diff_index_keeps_hot_paths_with_added_line_in_range() {
7993 let root = Path::new("/project");
7994 let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
7995 --- a/src/hot.ts\n\
7996 +++ b/src/hot.ts\n\
7997 @@ -10,1 +10,2 @@\n\
7998 + // touch the body\n\
7999 line 11\n";
8000 let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8001 let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8002 "fallow:hot:01010101",
8003 "src/hot.ts",
8004 7,
8005 24,
8006 )]);
8007
8008 apply_runtime_coverage_filters(
8009 &mut report,
8010 &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
8011 );
8012
8013 assert_eq!(report.hot_paths.len(), 1);
8014 assert_eq!(
8015 report.verdict,
8016 fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
8017 );
8018 }
8019
8020 #[test]
8021 fn runtime_coverage_diff_index_drops_hot_paths_when_added_line_outside_range() {
8022 let root = Path::new("/project");
8023 let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
8024 --- a/src/hot.ts\n\
8025 +++ b/src/hot.ts\n\
8026 @@ -50,1 +50,2 @@\n\
8027 + // unrelated change far below the hot function\n\
8028 line 51\n";
8029 let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8030 let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8031 "fallow:hot:02020202",
8032 "src/hot.ts",
8033 7,
8034 24,
8035 )]);
8036
8037 apply_runtime_coverage_filters(
8038 &mut report,
8039 &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
8040 );
8041
8042 assert!(report.hot_paths.is_empty());
8043 assert_eq!(
8044 report.verdict,
8045 fallow_output::RuntimeCoverageReportVerdict::Clean
8046 );
8047 }
8048
8049 #[test]
8050 fn runtime_coverage_diff_index_falls_back_to_single_line_when_end_line_zero() {
8051 let root = Path::new("/project");
8052 let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
8053 --- a/src/hot.ts\n\
8054 +++ b/src/hot.ts\n\
8055 @@ -7,1 +7,2 @@\n\
8056 + // exactly the function's start line\n\
8057 line 8\n";
8058 let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8059 let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8060 "fallow:hot:03030303",
8061 "src/hot.ts",
8062 7,
8063 0,
8064 )]);
8065
8066 apply_runtime_coverage_filters(
8067 &mut report,
8068 &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
8069 );
8070
8071 assert_eq!(report.hot_paths.len(), 1);
8072 assert_eq!(
8073 report.verdict,
8074 fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
8075 );
8076 }
8077
8078 #[test]
8079 fn runtime_coverage_diff_index_resolves_absolute_hot_path_against_root() {
8080 let root = Path::new("/project");
8081 let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
8082 --- a/src/hot.ts\n\
8083 +++ b/src/hot.ts\n\
8084 @@ -10,1 +10,2 @@\n\
8085 + // touched\n\
8086 line 11\n";
8087 let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8088 let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8089 "fallow:hot:04040404",
8090 "/project/src/hot.ts",
8091 7,
8092 24,
8093 )]);
8094
8095 apply_runtime_coverage_filters(
8096 &mut report,
8097 &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
8098 );
8099
8100 assert_eq!(report.hot_paths.len(), 1);
8101 }
8102
8103 #[test]
8104 fn runtime_coverage_diff_index_authoritative_for_files_in_diff() {
8105 let root = Path::new("/project");
8106 let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
8107 --- a/src/hot.ts\n\
8108 +++ b/src/hot.ts\n\
8109 @@ -50,1 +50,2 @@\n\
8110 + // outside the hot function\n\
8111 line 51\n";
8112 let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8113 let mut changed_files = FxHashSet::default();
8114 changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8115 let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8116 "fallow:hot:05050505",
8117 "src/hot.ts",
8118 7,
8119 24,
8120 )]);
8121
8122 apply_runtime_coverage_filters(
8123 &mut report,
8124 &RuntimeCoverageFilterContext::new(root)
8125 .with_changed_files(Some(&changed_files))
8126 .with_diff_index(Some(&diff_index)),
8127 );
8128
8129 assert!(report.hot_paths.is_empty());
8130 assert_eq!(
8131 report.verdict,
8132 fallow_output::RuntimeCoverageReportVerdict::Clean
8133 );
8134 }
8135
8136 #[test]
8137 fn runtime_coverage_per_file_fallback_to_changed_files_when_diff_omits_file() {
8138 let root = Path::new("/project");
8139 let diff = "diff --git a/src/other.ts b/src/other.ts\n\
8140 --- a/src/other.ts\n\
8141 +++ b/src/other.ts\n\
8142 @@ -1,1 +1,2 @@\n\
8143 + // unrelated\n\
8144 line 2\n";
8145 let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8146 let mut changed_files = FxHashSet::default();
8147 changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8148 let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8149 "fallow:hot:0a0a0a0a",
8150 "src/hot.ts",
8151 7,
8152 24,
8153 )]);
8154
8155 apply_runtime_coverage_filters(
8156 &mut report,
8157 &RuntimeCoverageFilterContext::new(root)
8158 .with_changed_files(Some(&changed_files))
8159 .with_diff_index(Some(&diff_index)),
8160 );
8161
8162 assert_eq!(report.hot_paths.len(), 1);
8163 assert_eq!(
8164 report.verdict,
8165 fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
8166 );
8167 }
8168
8169 #[test]
8170 fn runtime_coverage_pr_context_promotes_hot_path_touched_above_cold_code() {
8171 let root = Path::new("/project");
8172 let mut changed_files = FxHashSet::default();
8173 changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8174 let mut report = fallow_output::RuntimeCoverageReport {
8175 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
8176 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
8177 signals: Vec::new(),
8178 summary: fx_summary(2, 1, 1, 0),
8179 findings: vec![fallow_output::RuntimeCoverageFinding {
8180 id: "fallow:prod:cold0001".to_owned(),
8181 stable_id: None,
8182 path: PathBuf::from("/project/src/cold.ts"),
8183 function: "coldFn".to_owned(),
8184 line: 4,
8185 verdict: fallow_output::RuntimeCoverageVerdict::SafeToDelete,
8186 invocations: Some(0),
8187 confidence: fallow_output::RuntimeCoverageConfidence::High,
8188 evidence: fx_evidence("unused", "not_covered", "tracked"),
8189 actions: vec![],
8190 source_hash: None,
8191 discriminators: None,
8192 }],
8193 hot_paths: vec![fx_hot_path("fallow:hot:0b0b0b0b", "src/hot.ts", 7, 24)],
8194 blast_radius: vec![],
8195 importance: vec![],
8196 watermark: None,
8197 warnings: vec![],
8198 actionable: true,
8199 actionability_reason: None,
8200 actionability_verdict: None,
8201 provenance: fallow_output::RuntimeCoverageProvenance::default(),
8202 };
8203
8204 apply_runtime_coverage_filters(
8205 &mut report,
8206 &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
8207 );
8208
8209 assert_eq!(
8210 report.verdict,
8211 fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
8212 );
8213 assert_eq!(
8214 report.signals,
8215 vec![
8216 fallow_output::RuntimeCoverageSignal::ColdCodeDetected,
8217 fallow_output::RuntimeCoverageSignal::HotPathTouched,
8218 ]
8219 );
8220 }
8221
8222 #[test]
8223 fn runtime_coverage_standalone_keeps_cold_code_primary_above_unchanged_hot_paths() {
8224 let root = Path::new("/project");
8225 let mut report = fallow_output::RuntimeCoverageReport {
8226 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
8227 verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
8228 signals: Vec::new(),
8229 summary: fx_summary(2, 1, 1, 0),
8230 findings: vec![fallow_output::RuntimeCoverageFinding {
8231 id: "fallow:prod:cold0002".to_owned(),
8232 stable_id: None,
8233 path: PathBuf::from("/project/src/cold.ts"),
8234 function: "coldFn".to_owned(),
8235 line: 4,
8236 verdict: fallow_output::RuntimeCoverageVerdict::SafeToDelete,
8237 invocations: Some(0),
8238 confidence: fallow_output::RuntimeCoverageConfidence::High,
8239 evidence: fx_evidence("unused", "not_covered", "tracked"),
8240 actions: vec![],
8241 source_hash: None,
8242 discriminators: None,
8243 }],
8244 hot_paths: vec![fx_hot_path("fallow:hot:0c0c0c0c", "src/hot.ts", 7, 24)],
8245 blast_radius: vec![],
8246 importance: vec![],
8247 watermark: None,
8248 warnings: vec![],
8249 actionable: true,
8250 actionability_reason: None,
8251 actionability_verdict: None,
8252 provenance: fallow_output::RuntimeCoverageProvenance::default(),
8253 };
8254
8255 apply_runtime_coverage_filters(&mut report, &RuntimeCoverageFilterContext::new(root));
8256
8257 assert_eq!(
8258 report.verdict,
8259 fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected
8260 );
8261 assert_eq!(
8262 report.signals,
8263 vec![fallow_output::RuntimeCoverageSignal::ColdCodeDetected]
8264 );
8265 assert_eq!(report.hot_paths.len(), 1);
8266 }
8267
8268 #[test]
8269 fn runtime_coverage_license_grace_outranks_pr_context_signals() {
8270 let root = Path::new("/project");
8271 let mut changed_files = FxHashSet::default();
8272 changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8273 let mut report = fallow_output::RuntimeCoverageReport {
8274 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
8275 verdict: fallow_output::RuntimeCoverageReportVerdict::LicenseExpiredGrace,
8276 signals: Vec::new(),
8277 summary: fx_summary(2, 1, 1, 0),
8278 findings: vec![],
8279 hot_paths: vec![fx_hot_path("fallow:hot:0d0d0d0d", "src/hot.ts", 7, 24)],
8280 blast_radius: vec![],
8281 importance: vec![],
8282 watermark: Some(fallow_output::RuntimeCoverageWatermark::LicenseExpiredGrace),
8283 warnings: vec![],
8284 actionable: true,
8285 actionability_reason: None,
8286 actionability_verdict: None,
8287 provenance: fallow_output::RuntimeCoverageProvenance::default(),
8288 };
8289
8290 apply_runtime_coverage_filters(
8291 &mut report,
8292 &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
8293 );
8294
8295 assert_eq!(
8296 report.verdict,
8297 fallow_output::RuntimeCoverageReportVerdict::LicenseExpiredGrace
8298 );
8299 assert!(
8300 report
8301 .signals
8302 .contains(&fallow_output::RuntimeCoverageSignal::LicenseExpiredGrace)
8303 );
8304 assert!(
8305 report
8306 .signals
8307 .contains(&fallow_output::RuntimeCoverageSignal::HotPathTouched)
8308 );
8309 }
8310
8311 #[test]
8312 fn retain_hot_paths_drops_when_diff_touches_file_but_no_added_lines() {
8313 let root = Path::new("/project");
8314 let diff = fallow_output::DiffIndex::from_unified_diff(
8315 "diff --git a/src/hot.ts b/src/hot.ts\n\
8316 --- a/src/hot.ts\n\
8317 +++ b/src/hot.ts\n\
8318 @@ -10,3 +10,1 @@\n\
8319 -one\n\
8320 -two\n\
8321 -three\n\
8322 ctx\n",
8323 );
8324 let mut changed_files = FxHashSet::default();
8325 changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8326 let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8327 "fallow:hot:deletiononly",
8328 "src/hot.ts",
8329 10,
8330 12,
8331 )]);
8332
8333 apply_runtime_coverage_filters(
8334 &mut report,
8335 &RuntimeCoverageFilterContext::new(root)
8336 .with_diff_index(Some(&diff))
8337 .with_changed_files(Some(&changed_files)),
8338 );
8339
8340 assert!(
8341 report.hot_paths.is_empty(),
8342 "diff touched the file with no added lines: must drop, not fall through to changed_files"
8343 );
8344 }
8345
8346 #[test]
8347 fn runtime_coverage_changed_files_matches_relative_hot_path_against_absolute_set() {
8348 let root = Path::new("/project");
8349 let mut changed_files = FxHashSet::default();
8350 changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8351 let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8352 "fallow:hot:06060606",
8353 "src/hot.ts",
8354 7,
8355 24,
8356 )]);
8357
8358 apply_runtime_coverage_filters(
8359 &mut report,
8360 &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
8361 );
8362
8363 assert_eq!(report.hot_paths.len(), 1);
8364 }
8365
8366 fn make_class_finding(
8367 path: &str,
8368 name: &str,
8369 line: u32,
8370 cyclomatic: u16,
8371 cognitive: u16,
8372 ) -> ComplexityViolation {
8373 ComplexityViolation {
8374 path: PathBuf::from(path),
8375 name: name.to_string(),
8376 line,
8377 col: 0,
8378 cyclomatic,
8379 cognitive,
8380 line_count: 20,
8381 param_count: 0,
8382 react_hook_count: 0,
8383 react_jsx_max_depth: 0,
8384 react_prop_count: 0,
8385 react_hook_profile: None,
8386 exceeded: ExceededThreshold::Both,
8387 severity: FindingSeverity::Moderate,
8388 crap: None,
8389 coverage_pct: None,
8390 coverage_tier: None,
8391 coverage_source: None,
8392 inherited_from: None,
8393 component_rollup: None,
8394 contributions: Vec::new(),
8395 effective_thresholds: None,
8396 threshold_source: None,
8397 }
8398 }
8399
8400 fn make_template_finding(
8401 path: &str,
8402 line: u32,
8403 cyclomatic: u16,
8404 cognitive: u16,
8405 ) -> ComplexityViolation {
8406 ComplexityViolation {
8407 path: PathBuf::from(path),
8408 name: "<template>".to_string(),
8409 line,
8410 col: 0,
8411 cyclomatic,
8412 cognitive,
8413 line_count: 30,
8414 param_count: 0,
8415 react_hook_count: 0,
8416 react_jsx_max_depth: 0,
8417 react_prop_count: 0,
8418 react_hook_profile: None,
8419 exceeded: ExceededThreshold::Both,
8420 severity: FindingSeverity::Moderate,
8421 crap: None,
8422 coverage_pct: None,
8423 coverage_tier: None,
8424 coverage_source: None,
8425 inherited_from: None,
8426 component_rollup: None,
8427 contributions: Vec::new(),
8428 effective_thresholds: None,
8429 threshold_source: None,
8430 }
8431 }
8432
8433 #[test]
8434 fn rollup_external_template_via_provenance_lookup() {
8435 let component_ts = PathBuf::from("/proj/src/host-game.component.ts");
8436 let template_html = PathBuf::from("/proj/src/host-game.component.html");
8437 let mut findings = vec![
8438 make_class_finding(component_ts.to_str().unwrap(), "handleClick", 42, 3, 4),
8439 make_template_finding(template_html.to_str().unwrap(), 1, 6, 10),
8440 ];
8441 let mut lookup = rustc_hash::FxHashMap::default();
8442 lookup.insert(template_html.clone(), component_ts.clone());
8443 append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);
8444
8445 assert_eq!(findings.len(), 3, "rollup is strictly additive");
8446 let rollup = findings
8447 .iter()
8448 .find(|f| f.name == "<component>")
8449 .expect("rollup must be present");
8450 assert_eq!(rollup.path, component_ts);
8451 assert_eq!(rollup.cyclomatic, 9, "9 = worst class 3 + template 6");
8452 assert_eq!(rollup.cognitive, 14, "14 = worst class 4 + template 10");
8453 assert_eq!(rollup.line, 42, "anchored at worst class function line");
8454 let breakdown = rollup.component_rollup.as_ref().expect("breakdown present");
8455 assert_eq!(
8456 breakdown.component, "host-game.component",
8457 "component identifier is the .ts owner's file stem"
8458 );
8459 assert_eq!(breakdown.class_worst_function, "handleClick");
8460 assert_eq!(breakdown.class_cyclomatic, 3);
8461 assert_eq!(breakdown.template_cyclomatic, 6);
8462 assert_eq!(breakdown.template_path, template_html);
8463 }
8464
8465 #[test]
8466 fn rollup_inline_template_owner_is_same_ts_file() {
8467 let component_ts = PathBuf::from("/proj/src/inline.component.ts");
8468 let mut findings = vec![
8469 make_class_finding(component_ts.to_str().unwrap(), "ngOnInit", 25, 5, 8),
8470 make_template_finding(component_ts.to_str().unwrap(), 10, 4, 6),
8471 ];
8472 append_component_rollup_findings(&mut findings, None, 8, 8);
8473
8474 let rollup = findings
8475 .iter()
8476 .find(|f| f.name == "<component>")
8477 .expect("rollup must be present for inline-template case without provenance lookup");
8478 assert_eq!(rollup.cyclomatic, 9);
8479 assert_eq!(rollup.cognitive, 14);
8480 let breakdown = rollup.component_rollup.as_ref().unwrap();
8481 assert_eq!(breakdown.template_path, component_ts);
8482 assert_eq!(breakdown.component, "inline.component");
8483 }
8484
8485 #[test]
8486 fn rollup_picks_worst_class_function_by_cyclomatic() {
8487 let component_ts = PathBuf::from("/proj/src/multi.component.ts");
8488 let template = PathBuf::from("/proj/src/multi.component.html");
8489 let mut findings = vec![
8490 make_class_finding(component_ts.to_str().unwrap(), "first", 10, 3, 4),
8491 make_class_finding(component_ts.to_str().unwrap(), "worst", 20, 8, 9),
8492 make_class_finding(component_ts.to_str().unwrap(), "middle", 30, 5, 6),
8493 make_template_finding(template.to_str().unwrap(), 1, 4, 6),
8494 ];
8495 let mut lookup = rustc_hash::FxHashMap::default();
8496 lookup.insert(template, component_ts);
8497 append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);
8498
8499 let rollup = findings.iter().find(|f| f.name == "<component>").unwrap();
8500 assert_eq!(rollup.cyclomatic, 12, "8 (worst.cyc) + 4 (template.cyc)");
8501 let breakdown = rollup.component_rollup.as_ref().unwrap();
8502 assert_eq!(breakdown.class_worst_function, "worst");
8503 assert_eq!(breakdown.class_cyclomatic, 8);
8504 }
8505
8506 #[test]
8507 fn rollup_skipped_when_no_template_finding() {
8508 let component_ts = "/proj/src/only-class.component.ts";
8509 let mut findings = vec![make_class_finding(component_ts, "Foo.method", 10, 5, 7)];
8510 let before = findings.len();
8511 append_component_rollup_findings(&mut findings, None, 30, 25);
8512 assert_eq!(findings.len(), before, "no template means no rollup");
8513 }
8514
8515 #[test]
8516 fn rollup_skipped_when_no_class_findings() {
8517 let template_html = PathBuf::from("/proj/src/orphan.component.html");
8518 let component_ts = PathBuf::from("/proj/src/orphan.component.ts");
8519 let mut findings = vec![make_template_finding(
8520 template_html.to_str().unwrap(),
8521 1,
8522 6,
8523 10,
8524 )];
8525 let mut lookup = rustc_hash::FxHashMap::default();
8526 lookup.insert(template_html, component_ts);
8527 let before = findings.len();
8528 append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);
8529 assert_eq!(
8530 findings.len(),
8531 before,
8532 "no class methods above threshold means no rollup"
8533 );
8534 }
8535
8536 #[test]
8537 fn rollup_skipped_when_multiple_templates_on_one_owner() {
8538 let component_ts = PathBuf::from("/proj/src/twin.component.ts");
8539 let mut findings = vec![
8540 make_class_finding(component_ts.to_str().unwrap(), "TwinA.fn", 10, 5, 7),
8541 make_template_finding(component_ts.to_str().unwrap(), 5, 3, 4),
8542 make_template_finding(component_ts.to_str().unwrap(), 50, 4, 5),
8543 ];
8544 let before = findings.len();
8545 append_component_rollup_findings(&mut findings, None, 30, 25);
8546 assert_eq!(
8547 findings.len(),
8548 before,
8549 "two templates on one owner is defensively skipped"
8550 );
8551 }
8552
8553 #[test]
8554 fn rollup_external_template_skipped_when_lookup_missing() {
8555 let template_html = PathBuf::from("/proj/src/no-owner.component.html");
8556 let component_ts = "/proj/src/no-owner.component.ts";
8557 let mut findings = vec![
8558 make_class_finding(component_ts, "NoOwner.fn", 10, 5, 7),
8559 make_template_finding(template_html.to_str().unwrap(), 1, 6, 10),
8560 ];
8561 let before = findings.len();
8562 append_component_rollup_findings(&mut findings, None, 30, 25);
8563 assert_eq!(findings.len(), before);
8564 }
8565}