1use std::path::{Path, PathBuf};
4use std::process::ExitCode;
5use std::time::Duration;
6
7use fallow_config::{EmailMode, OutputFormat, ResolvedConfig};
8use fallow_output::{
9 DiffIndex, EffortEstimate, FindingSeverity, GroupByMode, HealthGrouping, HealthReport,
10 HealthTimings, RuntimeCoverageReport, RuntimeCoverageWatermark,
11};
12use fallow_types::path_util::is_absolute_path_any_platform;
13
14mod assembly;
15mod coverage_gaps;
16mod coverage_intelligence;
17mod execute;
18mod grouping;
19mod hotspots;
20pub mod ownership;
21mod react_hooks;
22mod runtime_filter;
23pub mod scoring;
24mod tailwind_theme;
25mod targets;
26
27pub(crate) use execute::{
28 HealthOptions, HealthReportAssembly, SubsetFilter, VitalSignsAndCountsInput,
29 apply_duplication_metrics, compute_vital_signs_and_counts,
30};
31pub use execute::{
32 HealthPipelineInputs, HealthResultGeneric, HealthScopeInputs, execute_health_inner,
33 validate_health_churn_file,
34};
35
36pub trait HealthGroupResolver {
42 fn mode_label(&self) -> &'static str;
44 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
46 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
48}
49
50#[derive(Debug, Clone, Copy)]
53pub enum NoGroupResolver {}
54
55#[expect(
56 clippy::uninhabited_references,
57 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
58)]
59impl HealthGroupResolver for NoGroupResolver {
60 fn mode_label(&self) -> &'static str {
61 match *self {}
62 }
63 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
64 match *self {}
65 }
66 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
67 match *self {}
68 }
69}
70
71pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(
78 &RuntimeCoverageOptions,
79 RuntimeCoverageSeamInput<'_>,
80 ) -> Result<RuntimeCoverageReport, ExitCode>
81 + 'a;
82
83pub struct RuntimeCoverageSeamInput<'a> {
85 pub root: &'a Path,
86 pub modules: &'a [fallow_types::extract::ModuleInfo],
87 pub analysis_output: &'a crate::DeadCodeAnalysisArtifacts,
88 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
89 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
90 pub ignore_set: &'a globset::GlobSet,
91 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
92 pub ws_roots: Option<&'a [PathBuf]>,
93 pub top: Option<usize>,
94 pub codeowners_path: Option<&'a str>,
95 pub quiet: bool,
96 pub output: OutputFormat,
97}
98
99pub struct HealthSeams<'a> {
103 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
105 pub note_graph_structure: &'a dyn Fn(usize, usize),
109}
110
111#[derive(Debug, Clone, Copy, Default)]
117pub struct HealthTelemetryFacts {
118 pub graph_structure: Option<(usize, usize)>,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum HealthSort {
125 Severity,
126 Cyclomatic,
127 Cognitive,
128 Lines,
129}
130
131#[derive(Debug, Clone, Copy, Default, PartialEq)]
133pub struct HealthThresholdOverrides {
134 pub max_cyclomatic: Option<u16>,
135 pub max_cognitive: Option<u16>,
136 pub max_crap: Option<f64>,
139}
140
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
143pub struct HealthCoverageInputs<'a> {
144 pub coverage: Option<&'a Path>,
145 pub coverage_root: Option<&'a Path>,
148}
149
150pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
157 if let Some(path) = coverage_root
158 && !is_absolute_path_any_platform(path)
159 {
160 return Err(format!(
161 "--coverage-root expects an absolute path prefix from the coverage data, got '{}'. Use the checkout prefix from the machine that generated coverage, for example '/home/runner/work/myapp'.",
162 path.display()
163 ));
164 }
165 Ok(())
166}
167
168#[derive(Debug, Clone, Copy, Default, PartialEq)]
170pub struct HealthGateOptions {
171 pub min_score: Option<f64>,
172 pub min_severity: Option<FindingSeverity>,
173 pub report_only: bool,
175}
176
177#[derive(Debug, Clone)]
179pub struct HealthSectionOptions {
180 pub output: OutputFormat,
181 pub complexity: bool,
182 pub file_scores: bool,
183 pub coverage_gaps: bool,
184 pub hotspots: bool,
185 pub targets: bool,
186 pub css: bool,
187 pub score: bool,
188 pub score_gate: bool,
189 pub snapshot_requested: bool,
190 pub trend: bool,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct DerivedHealthSections {
196 pub any_section: bool,
197 pub complexity: bool,
198 pub file_scores: bool,
199 pub coverage_gaps: bool,
200 pub hotspots: bool,
201 pub targets: bool,
202 pub css: bool,
203 pub score: bool,
204 pub force_full: bool,
205 pub score_only_output: bool,
206}
207
208#[derive(Debug, Clone)]
211pub struct HealthRunOptionsInput<'a> {
212 pub output: OutputFormat,
213 pub thresholds: HealthThresholdOverrides,
214 pub top: Option<usize>,
215 pub sort: HealthSort,
216 pub complexity: bool,
217 pub file_scores: bool,
218 pub coverage_gaps: bool,
219 pub hotspots: bool,
220 pub ownership: bool,
221 pub ownership_emails: Option<EmailMode>,
222 pub targets: bool,
223 pub css: bool,
224 pub effort: Option<EffortEstimate>,
225 pub score: bool,
226 pub gates: HealthGateOptions,
227 pub snapshot_requested: bool,
228 pub trend: bool,
229 pub since: Option<&'a str>,
230 pub min_commits: Option<u32>,
231 pub coverage_inputs: HealthCoverageInputs<'a>,
232 pub runtime_coverage: Option<RuntimeCoverageOptions>,
233}
234
235#[derive(Debug, Clone)]
237pub struct HealthRunOptions<'a> {
238 pub thresholds: HealthThresholdOverrides,
239 pub top: Option<usize>,
240 pub sort: HealthSort,
241 pub sections: DerivedHealthSections,
242 pub ownership: bool,
243 pub ownership_emails: Option<EmailMode>,
244 pub effort: Option<EffortEstimate>,
245 pub gates: HealthGateOptions,
246 pub since: Option<&'a str>,
247 pub min_commits: Option<u32>,
248 pub coverage_inputs: HealthCoverageInputs<'a>,
249 pub runtime_coverage: Option<RuntimeCoverageOptions>,
250}
251
252#[derive(Debug, Clone)]
256pub struct HealthExecutionOptions<'a> {
257 pub root: &'a Path,
258 pub config_path: &'a Option<PathBuf>,
259 pub output: OutputFormat,
260 pub no_cache: bool,
261 pub threads: usize,
262 pub quiet: bool,
263 pub complexity_breakdown: bool,
268 pub thresholds: HealthThresholdOverrides,
269 pub top: Option<usize>,
270 pub sort: HealthSort,
271 pub production: bool,
272 pub production_override: Option<bool>,
273 pub changed_since: Option<&'a str>,
274 pub diff_index: Option<&'a DiffIndex>,
275 pub use_shared_diff_index: bool,
276 pub workspace: Option<&'a [String]>,
277 pub changed_workspaces: Option<&'a str>,
278 pub baseline: Option<&'a Path>,
279 pub save_baseline: Option<&'a Path>,
280 pub complexity: bool,
281 pub file_scores: bool,
282 pub coverage_gaps: bool,
283 pub config_activates_coverage_gaps: bool,
284 pub hotspots: bool,
285 pub ownership: bool,
286 pub ownership_emails: Option<EmailMode>,
287 pub targets: bool,
288 pub css: bool,
289 pub force_full: bool,
290 pub score_only_output: bool,
291 pub enforce_coverage_gap_gate: bool,
292 pub effort: Option<EffortEstimate>,
293 pub score: bool,
294 pub gates: HealthGateOptions,
295 pub since: Option<&'a str>,
296 pub min_commits: Option<u32>,
297 pub explain: bool,
298 pub summary: bool,
299 pub save_snapshot: Option<PathBuf>,
300 pub trend: bool,
301 pub coverage_inputs: HealthCoverageInputs<'a>,
302 pub performance: bool,
303 pub runtime_coverage: Option<RuntimeCoverageOptions>,
304 pub churn_file: Option<&'a Path>,
305 pub group_by: Option<GroupByMode>,
307}
308
309#[must_use]
311pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
312 let score = options.score
313 || options.score_gate
314 || options.trend
315 || matches!(options.output, OutputFormat::Badge);
316 let any_section = options.complexity
317 || options.file_scores
318 || options.coverage_gaps
319 || options.hotspots
320 || options.targets
321 || score;
322 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
323 let force_full = options.snapshot_requested || effective_score;
324
325 DerivedHealthSections {
326 any_section,
327 complexity: if any_section {
328 options.complexity
329 } else {
330 true
331 },
332 file_scores: if any_section {
333 options.file_scores
334 } else {
335 true
336 } || force_full,
337 coverage_gaps: if any_section {
338 options.coverage_gaps
339 } else {
340 false
341 },
342 hotspots: if any_section { options.hotspots } else { true }
343 || options.snapshot_requested
344 || options.trend,
345 targets: if any_section { options.targets } else { true },
346 css: options.css,
347 score: effective_score,
348 force_full,
349 score_only_output: is_health_score_only_output(options, score),
350 }
351}
352
353#[must_use]
355pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
356 let targets = input.targets || input.effort.is_some();
357 let sections = derive_health_sections(&HealthSectionOptions {
358 output: input.output,
359 complexity: input.complexity,
360 file_scores: input.file_scores,
361 coverage_gaps: input.coverage_gaps,
362 hotspots: input.hotspots,
363 targets,
364 css: input.css,
365 score: input.score,
366 score_gate: input.gates.min_score.is_some(),
367 snapshot_requested: input.snapshot_requested,
368 trend: input.trend,
369 });
370
371 HealthRunOptions {
372 thresholds: input.thresholds,
373 top: input.top,
374 sort: input.sort,
375 sections,
376 ownership: input.ownership && sections.hotspots,
377 ownership_emails: input.ownership_emails,
378 effort: input.effort,
379 gates: input.gates,
380 since: input.since,
381 min_commits: input.min_commits,
382 coverage_inputs: input.coverage_inputs,
383 runtime_coverage: input.runtime_coverage,
384 }
385}
386
387fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
388 score
389 && !options.complexity
390 && !options.file_scores
391 && !options.coverage_gaps
392 && !options.hotspots
393 && !options.targets
394 && !options.trend
395}
396
397#[derive(Debug, Clone)]
399pub struct ComplexitySectionOptions {
400 pub complexity: bool,
401 pub file_scores: bool,
402 pub coverage_gaps: bool,
403 pub hotspots: bool,
404 pub ownership: bool,
405 pub targets: bool,
406 pub css: bool,
407 pub score: bool,
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412pub struct DerivedComplexityOptions {
413 pub any_section: bool,
414 pub complexity: bool,
415 pub file_scores: bool,
416 pub coverage_gaps: bool,
417 pub hotspots: bool,
418 pub ownership: bool,
419 pub targets: bool,
420 pub force_full: bool,
421 pub score_only_output: bool,
422 pub score: bool,
423}
424
425#[must_use]
427pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
428 let requested_hotspots = options.hotspots || options.ownership;
429 let sections = derive_health_sections(&HealthSectionOptions {
430 output: OutputFormat::Human,
431 complexity: options.complexity,
432 file_scores: options.file_scores,
433 coverage_gaps: options.coverage_gaps,
434 hotspots: requested_hotspots,
435 targets: options.targets,
436 css: options.css,
437 score: options.score,
438 score_gate: false,
439 snapshot_requested: false,
440 trend: false,
441 });
442
443 DerivedComplexityOptions {
444 any_section: sections.any_section,
445 complexity: sections.complexity,
446 file_scores: sections.file_scores,
447 coverage_gaps: sections.coverage_gaps,
448 hotspots: sections.hotspots,
449 ownership: options.ownership && sections.hotspots,
450 targets: sections.targets,
451 force_full: sections.force_full,
452 score_only_output: sections.score_only_output,
453 score: sections.score,
454 }
455}
456
457#[derive(Debug, Clone, PartialEq)]
460pub struct ComplexityRunOptions<'a> {
461 pub thresholds: HealthThresholdOverrides,
462 pub top: Option<usize>,
463 pub sort: HealthSort,
464 pub sections: DerivedComplexityOptions,
465 pub ownership_emails: Option<EmailMode>,
466 pub effort: Option<EffortEstimate>,
467 pub css: bool,
468 pub since: Option<&'a str>,
469 pub min_commits: Option<u32>,
470 pub coverage_inputs: HealthCoverageInputs<'a>,
471}
472
473#[derive(Debug, Clone)]
475pub struct RuntimeCoverageOptions {
476 pub path: PathBuf,
477 pub min_invocations_hot: u64,
478 pub min_observation_volume: Option<u32>,
483 pub low_traffic_threshold: Option<f64>,
487 pub license_jwt: String,
488 pub watermark: Option<RuntimeCoverageWatermark>,
489}
490
491pub struct HealthSharedParseData {
493 pub files: Vec<fallow_types::discover::DiscoveredFile>,
494 pub modules: Vec<fallow_types::extract::ModuleInfo>,
495 pub analysis_output: Option<crate::DeadCodeAnalysisArtifacts>,
497}
498
499#[derive(Debug)]
504pub struct HealthAnalysisResult<GroupResolver = ()> {
505 pub report: HealthReport,
506 pub grouping: Option<HealthGrouping>,
512 pub group_resolver: Option<GroupResolver>,
515 pub config: ResolvedConfig,
516 pub elapsed: Duration,
517 pub timings: Option<HealthTimings>,
518 pub coverage_gaps_has_findings: bool,
519 pub should_fail_on_coverage_gaps: bool,
520}
521
522impl<GroupResolver> HealthAnalysisResult<GroupResolver> {
523 #[must_use]
526 pub fn without_group_resolver(self) -> HealthAnalysisResult<()> {
527 HealthAnalysisResult {
528 report: self.report,
529 grouping: self.grouping,
530 group_resolver: None,
531 config: self.config,
532 elapsed: self.elapsed,
533 timings: self.timings,
534 coverage_gaps_has_findings: self.coverage_gaps_has_findings,
535 should_fail_on_coverage_gaps: self.should_fail_on_coverage_gaps,
536 }
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 fn health_run_input() -> HealthRunOptionsInput<'static> {
545 HealthRunOptionsInput {
546 output: OutputFormat::Json,
547 thresholds: HealthThresholdOverrides::default(),
548 top: None,
549 sort: HealthSort::Cyclomatic,
550 complexity: false,
551 file_scores: false,
552 coverage_gaps: false,
553 hotspots: false,
554 ownership: false,
555 ownership_emails: None,
556 targets: false,
557 css: false,
558 effort: None,
559 score: false,
560 gates: HealthGateOptions::default(),
561 snapshot_requested: false,
562 trend: false,
563 since: None,
564 min_commits: None,
565 coverage_inputs: HealthCoverageInputs::default(),
566 runtime_coverage: None,
567 }
568 }
569
570 #[test]
571 fn health_analysis_result_drops_presentation_resolver() {
572 let project = tempfile::tempdir().expect("temp dir");
573 let project_config = crate::config_for_project_analysis(
574 project.path(),
575 None,
576 crate::ProjectConfigOptions {
577 output: OutputFormat::Json,
578 no_cache: true,
579 threads: 1,
580 production_override: None,
581 quiet: true,
582 analysis: fallow_config::ProductionAnalysis::Health,
583 },
584 )
585 .expect("project config loads");
586 let result = HealthAnalysisResult {
587 report: HealthReport::default(),
588 grouping: None,
589 group_resolver: Some("resolver"),
590 config: project_config.config,
591 elapsed: Duration::from_millis(7),
592 timings: None,
593 coverage_gaps_has_findings: true,
594 should_fail_on_coverage_gaps: true,
595 };
596
597 let neutral = result.without_group_resolver();
598
599 assert!(neutral.group_resolver.is_none());
600 assert_eq!(neutral.elapsed, Duration::from_millis(7));
601 assert!(neutral.coverage_gaps_has_findings);
602 assert!(neutral.should_fail_on_coverage_gaps);
603 }
604
605 #[test]
606 fn health_execution_options_own_shared_runner_scope() {
607 let root = Path::new("/project");
608 let config_path = None;
609 let workspace = vec!["packages/app".to_string()];
610 let diff = DiffIndex::from_unified_diff(
611 "diff --git a/src/a.ts b/src/a.ts\n\
612 --- a/src/a.ts\n\
613 +++ b/src/a.ts\n\
614 @@ -0,0 +1,1 @@\n\
615 +new line\n",
616 );
617 let runtime_coverage = RuntimeCoverageOptions {
618 path: PathBuf::from("coverage/v8"),
619 min_invocations_hot: 10,
620 min_observation_volume: Some(500),
621 low_traffic_threshold: Some(0.01),
622 license_jwt: "test.jwt".to_string(),
623 watermark: None,
624 };
625
626 let options = HealthExecutionOptions {
627 root,
628 config_path: &config_path,
629 output: OutputFormat::Json,
630 no_cache: true,
631 threads: 2,
632 quiet: true,
633 complexity_breakdown: true,
634 thresholds: HealthThresholdOverrides::default(),
635 top: Some(5),
636 sort: HealthSort::Cognitive,
637 production: true,
638 production_override: Some(true),
639 changed_since: Some("HEAD~1"),
640 diff_index: Some(&diff),
641 use_shared_diff_index: false,
642 workspace: Some(&workspace),
643 changed_workspaces: None,
644 baseline: Some(Path::new(".fallow/health-baseline.json")),
645 save_baseline: None,
646 complexity: true,
647 file_scores: true,
648 coverage_gaps: false,
649 config_activates_coverage_gaps: false,
650 hotspots: true,
651 ownership: false,
652 ownership_emails: None,
653 targets: true,
654 css: false,
655 force_full: true,
656 score_only_output: false,
657 enforce_coverage_gap_gate: true,
658 effort: Some(EffortEstimate::Low),
659 score: true,
660 gates: HealthGateOptions {
661 min_score: Some(80.0),
662 min_severity: None,
663 report_only: false,
664 },
665 since: Some("30d"),
666 min_commits: Some(2),
667 explain: true,
668 summary: false,
669 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
670 trend: true,
671 coverage_inputs: HealthCoverageInputs::default(),
672 performance: true,
673 runtime_coverage: Some(runtime_coverage),
674 churn_file: Some(Path::new("churn.json")),
675 group_by: Some(GroupByMode::Directory),
676 };
677
678 assert_eq!(options.root, root);
679 assert!(
680 options
681 .diff_index
682 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
683 );
684 assert_eq!(options.workspace, Some(workspace.as_slice()));
685 assert!(options.runtime_coverage.is_some());
686 assert_eq!(options.group_by, Some(GroupByMode::Directory));
687 assert_eq!(
688 options.save_snapshot.as_deref(),
689 Some(Path::new(".fallow/snapshots/health.json"))
690 );
691 }
692
693 #[test]
694 fn health_run_options_default_sections_match_health_defaults() {
695 let run = derive_health_run_options(health_run_input());
696
697 assert!(run.sections.complexity);
698 assert!(run.sections.file_scores);
699 assert!(run.sections.hotspots);
700 assert!(run.sections.targets);
701 assert!(run.sections.score);
702 assert!(!run.ownership);
703 }
704
705 #[test]
706 fn health_run_options_effort_requests_targets() {
707 let mut input = health_run_input();
708 input.effort = Some(EffortEstimate::Low);
709
710 let run = derive_health_run_options(input);
711
712 assert!(run.sections.targets);
713 assert_eq!(run.effort, Some(EffortEstimate::Low));
714 }
715
716 #[test]
717 fn health_run_options_ownership_requires_hotspots() {
718 let mut input = health_run_input();
719 input.complexity = true;
720 input.ownership = true;
721
722 let run = derive_health_run_options(input);
723
724 assert!(!run.sections.hotspots);
725 assert!(!run.ownership);
726
727 let mut input = health_run_input();
728 input.ownership = true;
729 input.hotspots = true;
730
731 let run = derive_health_run_options(input);
732
733 assert!(run.sections.hotspots);
734 assert!(run.ownership);
735 }
736
737 #[test]
738 fn health_run_options_score_gate_forces_score() {
739 let mut input = health_run_input();
740 input.gates.min_score = Some(90.0);
741
742 let run = derive_health_run_options(input);
743
744 assert!(run.sections.score);
745 assert_eq!(run.gates.min_score, Some(90.0));
746 }
747
748 #[test]
749 fn coverage_root_accepts_posix_absolute() {
750 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
751 assert!(
752 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
753 );
754 }
755
756 #[test]
757 fn coverage_root_rejects_relative() {
758 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
759 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
760 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
761 }
762
763 #[test]
764 fn coverage_root_accepts_none() {
765 assert!(validate_coverage_root_absolute(None).is_ok());
766 }
767
768 #[test]
769 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
770 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
771 }
772}