1use std::path::{Path, PathBuf};
4use std::process::ExitCode;
5
6use fallow_config::EmailMode;
7use fallow_output::{
8 DiffIndex, EffortEstimate, FindingSeverity, GroupByMode, RuntimeCoverageReport,
9 RuntimeCoverageWatermark,
10};
11use fallow_types::output_format::OutputFormat;
12use fallow_types::path_util::is_absolute_path_any_platform;
13
14mod actions;
15mod analysis_data;
16mod assembly;
17mod baseline_io;
18mod churn_file;
19mod component_rollup;
20mod core_pipeline;
21mod coverage_gaps;
22mod coverage_intelligence;
23mod coverage_settings;
24mod css_analytics;
25mod derived_sections;
26mod execute;
27mod file_scores;
28mod filters;
29mod finding_sort;
30mod findings;
31mod findings_pipeline;
32mod framework_health;
33mod grouping;
34mod hotspots;
35mod ignore;
36mod large_functions;
37mod output_build;
38pub mod ownership;
39mod package_json;
40mod pipeline;
41mod react_hooks;
42mod result;
43mod runner;
44mod runtime_filter;
45mod runtime_sections;
46mod scope;
47pub mod scoring;
48pub mod styling_score;
49mod tailwind_theme;
50mod targets;
51mod threshold_overrides;
52mod timings;
53mod vital_data;
54mod vital_signs_scope;
55
56pub use crate::results::HealthAnalysisResult;
57pub use churn_file::validate_health_churn_file;
58use derived_sections::{
59 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
60};
61use execute::HealthOptions;
62pub use execute::execute_health_inner;
63use file_scores::{
64 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
65 print_slow_churn_note,
66};
67use finding_sort::sort_findings;
68pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
69pub use runner::run_ungrouped_health;
70use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
71use vital_signs_scope::{
72 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
73 compute_vital_signs_and_counts,
74};
75
76pub trait HealthGroupResolver {
82 fn mode_label(&self) -> &'static str;
84 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
86 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
88}
89
90#[derive(Debug, Clone, Copy)]
93pub enum NoGroupResolver {}
94
95#[expect(
96 clippy::uninhabited_references,
97 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
98)]
99impl HealthGroupResolver for NoGroupResolver {
100 fn mode_label(&self) -> &'static str {
101 match *self {}
102 }
103 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
104 match *self {}
105 }
106 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
107 match *self {}
108 }
109}
110
111pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(
118 &RuntimeCoverageOptions,
119 RuntimeCoverageSeamInput<'_>,
120 ) -> Result<RuntimeCoverageReport, ExitCode>
121 + 'a;
122
123pub struct RuntimeCoverageSeamInput<'a> {
125 pub root: &'a Path,
126 pub modules: &'a [fallow_types::extract::ModuleInfo],
127 pub analysis_output: &'a crate::DeadCodeAnalysisArtifacts,
128 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
129 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
130 pub ignore_set: &'a globset::GlobSet,
131 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
132 pub ws_roots: Option<&'a [PathBuf]>,
133 pub top: Option<usize>,
134 pub codeowners_path: Option<&'a str>,
135 pub quiet: bool,
136 pub output: OutputFormat,
137}
138
139pub struct HealthSeams<'a> {
143 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
145 pub note_graph_structure: &'a dyn Fn(usize, usize),
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum HealthSort {
154 Severity,
155 Cyclomatic,
156 Cognitive,
157 Lines,
158}
159
160#[derive(Debug, Clone, Copy, Default, PartialEq)]
162pub struct HealthThresholdOverrides {
163 pub max_cyclomatic: Option<u16>,
164 pub max_cognitive: Option<u16>,
165 pub max_crap: Option<f64>,
168}
169
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
172pub struct HealthCoverageInputs<'a> {
173 pub coverage: Option<&'a Path>,
174 pub coverage_root: Option<&'a Path>,
177}
178
179pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
186 if let Some(path) = coverage_root
187 && !is_absolute_path_any_platform(path)
188 {
189 return Err(format!(
190 "--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'.",
191 path.display()
192 ));
193 }
194 Ok(())
195}
196
197#[derive(Debug, Clone, Copy, Default, PartialEq)]
199pub struct HealthGateOptions {
200 pub min_score: Option<f64>,
201 pub min_severity: Option<FindingSeverity>,
202 pub report_only: bool,
204}
205
206#[derive(Debug, Clone)]
208pub struct HealthSectionOptions {
209 pub output: OutputFormat,
210 pub complexity: bool,
211 pub file_scores: bool,
212 pub coverage_gaps: bool,
213 pub hotspots: bool,
214 pub targets: bool,
215 pub css: bool,
216 pub score: bool,
217 pub score_gate: bool,
218 pub snapshot_requested: bool,
219 pub trend: bool,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct DerivedHealthSections {
225 pub any_section: bool,
226 pub complexity: bool,
227 pub file_scores: bool,
228 pub coverage_gaps: bool,
229 pub hotspots: bool,
230 pub targets: bool,
231 pub css: bool,
232 pub score: bool,
233 pub force_full: bool,
234 pub score_only_output: bool,
235}
236
237#[derive(Debug, Clone)]
240pub struct HealthRunOptionsInput<'a> {
241 pub output: OutputFormat,
242 pub thresholds: HealthThresholdOverrides,
243 pub top: Option<usize>,
244 pub sort: HealthSort,
245 pub complexity: bool,
246 pub file_scores: bool,
247 pub coverage_gaps: bool,
248 pub hotspots: bool,
249 pub ownership: bool,
250 pub ownership_emails: Option<EmailMode>,
251 pub targets: bool,
252 pub css: bool,
253 pub effort: Option<EffortEstimate>,
254 pub score: bool,
255 pub gates: HealthGateOptions,
256 pub snapshot_requested: bool,
257 pub trend: bool,
258 pub since: Option<&'a str>,
259 pub min_commits: Option<u32>,
260 pub coverage_inputs: HealthCoverageInputs<'a>,
261 pub runtime_coverage: Option<RuntimeCoverageOptions>,
262}
263
264#[derive(Debug, Clone)]
266pub struct HealthRunOptions<'a> {
267 pub thresholds: HealthThresholdOverrides,
268 pub top: Option<usize>,
269 pub sort: HealthSort,
270 pub sections: DerivedHealthSections,
271 pub ownership: bool,
272 pub ownership_emails: Option<EmailMode>,
273 pub effort: Option<EffortEstimate>,
274 pub gates: HealthGateOptions,
275 pub since: Option<&'a str>,
276 pub min_commits: Option<u32>,
277 pub coverage_inputs: HealthCoverageInputs<'a>,
278 pub runtime_coverage: Option<RuntimeCoverageOptions>,
279}
280
281#[derive(Debug, Clone)]
285pub struct HealthExecutionOptions<'a> {
286 pub root: &'a Path,
287 pub config_path: &'a Option<PathBuf>,
288 pub output: OutputFormat,
289 pub no_cache: bool,
290 pub threads: usize,
291 pub quiet: bool,
292 pub complexity_breakdown: bool,
297 pub thresholds: HealthThresholdOverrides,
298 pub top: Option<usize>,
299 pub sort: HealthSort,
300 pub production: bool,
301 pub production_override: Option<bool>,
302 pub changed_since: Option<&'a str>,
303 pub diff_index: Option<&'a DiffIndex>,
304 pub use_shared_diff_index: bool,
305 pub workspace: Option<&'a [String]>,
306 pub changed_workspaces: Option<&'a str>,
307 pub baseline: Option<&'a Path>,
308 pub save_baseline: Option<&'a Path>,
309 pub complexity: bool,
310 pub file_scores: bool,
311 pub coverage_gaps: bool,
312 pub config_activates_coverage_gaps: bool,
313 pub hotspots: bool,
314 pub ownership: bool,
315 pub ownership_emails: Option<EmailMode>,
316 pub targets: bool,
317 pub css: bool,
318 pub force_full: bool,
319 pub score_only_output: bool,
320 pub enforce_coverage_gap_gate: bool,
321 pub effort: Option<EffortEstimate>,
322 pub score: bool,
323 pub gates: HealthGateOptions,
324 pub since: Option<&'a str>,
325 pub min_commits: Option<u32>,
326 pub explain: bool,
327 pub summary: bool,
328 pub save_snapshot: Option<PathBuf>,
329 pub trend: bool,
330 pub coverage_inputs: HealthCoverageInputs<'a>,
331 pub performance: bool,
332 pub runtime_coverage: Option<RuntimeCoverageOptions>,
333 pub churn_file: Option<&'a Path>,
334 pub group_by: Option<GroupByMode>,
336}
337
338#[must_use]
340pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
341 let score = options.score
342 || options.score_gate
343 || options.trend
344 || matches!(options.output, OutputFormat::Badge);
345 let any_section = options.complexity
346 || options.file_scores
347 || options.coverage_gaps
348 || options.hotspots
349 || options.targets
350 || score;
351 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
352 let force_full = options.snapshot_requested || effective_score;
353
354 DerivedHealthSections {
355 any_section,
356 complexity: if any_section {
357 options.complexity
358 } else {
359 true
360 },
361 file_scores: if any_section {
362 options.file_scores
363 } else {
364 true
365 } || force_full,
366 coverage_gaps: if any_section {
367 options.coverage_gaps
368 } else {
369 false
370 },
371 hotspots: if any_section { options.hotspots } else { true }
372 || options.snapshot_requested
373 || options.trend,
374 targets: if any_section { options.targets } else { true },
375 css: options.css,
376 score: effective_score,
377 force_full,
378 score_only_output: is_health_score_only_output(options, score),
379 }
380}
381
382#[must_use]
384pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
385 let targets = input.targets || input.effort.is_some();
386 let sections = derive_health_sections(&HealthSectionOptions {
387 output: input.output,
388 complexity: input.complexity,
389 file_scores: input.file_scores,
390 coverage_gaps: input.coverage_gaps,
391 hotspots: input.hotspots,
392 targets,
393 css: input.css,
394 score: input.score,
395 score_gate: input.gates.min_score.is_some(),
396 snapshot_requested: input.snapshot_requested,
397 trend: input.trend,
398 });
399
400 HealthRunOptions {
401 thresholds: input.thresholds,
402 top: input.top,
403 sort: input.sort,
404 sections,
405 ownership: input.ownership && sections.hotspots,
406 ownership_emails: input.ownership_emails,
407 effort: input.effort,
408 gates: input.gates,
409 since: input.since,
410 min_commits: input.min_commits,
411 coverage_inputs: input.coverage_inputs,
412 runtime_coverage: input.runtime_coverage,
413 }
414}
415
416fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
417 score
418 && !options.complexity
419 && !options.file_scores
420 && !options.coverage_gaps
421 && !options.hotspots
422 && !options.targets
423 && !options.trend
424}
425
426#[derive(Debug, Clone)]
428pub struct ComplexitySectionOptions {
429 pub complexity: bool,
430 pub file_scores: bool,
431 pub coverage_gaps: bool,
432 pub hotspots: bool,
433 pub ownership: bool,
434 pub targets: bool,
435 pub css: bool,
436 pub score: bool,
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub struct DerivedComplexityOptions {
442 pub any_section: bool,
443 pub complexity: bool,
444 pub file_scores: bool,
445 pub coverage_gaps: bool,
446 pub hotspots: bool,
447 pub ownership: bool,
448 pub targets: bool,
449 pub force_full: bool,
450 pub score_only_output: bool,
451 pub score: bool,
452}
453
454#[must_use]
456pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
457 let requested_hotspots = options.hotspots || options.ownership;
458 let sections = derive_health_sections(&HealthSectionOptions {
459 output: OutputFormat::Human,
460 complexity: options.complexity,
461 file_scores: options.file_scores,
462 coverage_gaps: options.coverage_gaps,
463 hotspots: requested_hotspots,
464 targets: options.targets,
465 css: options.css,
466 score: options.score,
467 score_gate: false,
468 snapshot_requested: false,
469 trend: false,
470 });
471
472 DerivedComplexityOptions {
473 any_section: sections.any_section,
474 complexity: sections.complexity,
475 file_scores: sections.file_scores,
476 coverage_gaps: sections.coverage_gaps,
477 hotspots: sections.hotspots,
478 ownership: options.ownership && sections.hotspots,
479 targets: sections.targets,
480 force_full: sections.force_full,
481 score_only_output: sections.score_only_output,
482 score: sections.score,
483 }
484}
485
486#[derive(Debug, Clone, PartialEq)]
489pub struct ComplexityRunOptions<'a> {
490 pub thresholds: HealthThresholdOverrides,
491 pub top: Option<usize>,
492 pub sort: HealthSort,
493 pub complexity_breakdown: bool,
494 pub sections: DerivedComplexityOptions,
495 pub ownership_emails: Option<EmailMode>,
496 pub effort: Option<EffortEstimate>,
497 pub css: bool,
498 pub since: Option<&'a str>,
499 pub min_commits: Option<u32>,
500 pub coverage_inputs: HealthCoverageInputs<'a>,
501}
502
503#[derive(Debug, Clone)]
505pub struct RuntimeCoverageOptions {
506 pub path: PathBuf,
507 pub min_invocations_hot: u64,
508 pub min_observation_volume: Option<u32>,
513 pub low_traffic_threshold: Option<f64>,
517 pub license_jwt: String,
518 pub watermark: Option<RuntimeCoverageWatermark>,
519}
520
521pub struct HealthSharedParseData {
523 pub files: Vec<fallow_types::discover::DiscoveredFile>,
524 pub modules: Vec<fallow_types::extract::ModuleInfo>,
525 pub analysis_output: Option<crate::DeadCodeAnalysisArtifacts>,
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 fn health_run_input() -> HealthRunOptionsInput<'static> {
534 HealthRunOptionsInput {
535 output: OutputFormat::Json,
536 thresholds: HealthThresholdOverrides::default(),
537 top: None,
538 sort: HealthSort::Cyclomatic,
539 complexity: false,
540 file_scores: false,
541 coverage_gaps: false,
542 hotspots: false,
543 ownership: false,
544 ownership_emails: None,
545 targets: false,
546 css: false,
547 effort: None,
548 score: false,
549 gates: HealthGateOptions::default(),
550 snapshot_requested: false,
551 trend: false,
552 since: None,
553 min_commits: None,
554 coverage_inputs: HealthCoverageInputs::default(),
555 runtime_coverage: None,
556 }
557 }
558
559 #[test]
560 fn health_execution_options_own_shared_runner_scope() {
561 let root = Path::new("/project");
562 let config_path = None;
563 let workspace = vec!["packages/app".to_string()];
564 let diff = DiffIndex::from_unified_diff(
565 "diff --git a/src/a.ts b/src/a.ts\n\
566 --- a/src/a.ts\n\
567 +++ b/src/a.ts\n\
568 @@ -0,0 +1,1 @@\n\
569 +new line\n",
570 );
571 let runtime_coverage = RuntimeCoverageOptions {
572 path: PathBuf::from("coverage/v8"),
573 min_invocations_hot: 10,
574 min_observation_volume: Some(500),
575 low_traffic_threshold: Some(0.01),
576 license_jwt: "test.jwt".to_string(),
577 watermark: None,
578 };
579
580 let options = HealthExecutionOptions {
581 root,
582 config_path: &config_path,
583 output: OutputFormat::Json,
584 no_cache: true,
585 threads: 2,
586 quiet: true,
587 complexity_breakdown: true,
588 thresholds: HealthThresholdOverrides::default(),
589 top: Some(5),
590 sort: HealthSort::Cognitive,
591 production: true,
592 production_override: Some(true),
593 changed_since: Some("HEAD~1"),
594 diff_index: Some(&diff),
595 use_shared_diff_index: false,
596 workspace: Some(&workspace),
597 changed_workspaces: None,
598 baseline: Some(Path::new(".fallow/health-baseline.json")),
599 save_baseline: None,
600 complexity: true,
601 file_scores: true,
602 coverage_gaps: false,
603 config_activates_coverage_gaps: false,
604 hotspots: true,
605 ownership: false,
606 ownership_emails: None,
607 targets: true,
608 css: false,
609 force_full: true,
610 score_only_output: false,
611 enforce_coverage_gap_gate: true,
612 effort: Some(EffortEstimate::Low),
613 score: true,
614 gates: HealthGateOptions {
615 min_score: Some(80.0),
616 min_severity: None,
617 report_only: false,
618 },
619 since: Some("30d"),
620 min_commits: Some(2),
621 explain: true,
622 summary: false,
623 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
624 trend: true,
625 coverage_inputs: HealthCoverageInputs::default(),
626 performance: true,
627 runtime_coverage: Some(runtime_coverage),
628 churn_file: Some(Path::new("churn.json")),
629 group_by: Some(GroupByMode::Directory),
630 };
631
632 assert_eq!(options.root, root);
633 assert!(
634 options
635 .diff_index
636 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
637 );
638 assert_eq!(options.workspace, Some(workspace.as_slice()));
639 assert!(options.runtime_coverage.is_some());
640 assert_eq!(options.group_by, Some(GroupByMode::Directory));
641 assert_eq!(
642 options.save_snapshot.as_deref(),
643 Some(Path::new(".fallow/snapshots/health.json"))
644 );
645 }
646
647 #[test]
648 fn health_run_options_default_sections_match_health_defaults() {
649 let run = derive_health_run_options(health_run_input());
650
651 assert!(run.sections.complexity);
652 assert!(run.sections.file_scores);
653 assert!(run.sections.hotspots);
654 assert!(run.sections.targets);
655 assert!(run.sections.score);
656 assert!(!run.ownership);
657 }
658
659 #[test]
660 fn health_run_options_effort_requests_targets() {
661 let mut input = health_run_input();
662 input.effort = Some(EffortEstimate::Low);
663
664 let run = derive_health_run_options(input);
665
666 assert!(run.sections.targets);
667 assert_eq!(run.effort, Some(EffortEstimate::Low));
668 }
669
670 #[test]
671 fn health_run_options_ownership_requires_hotspots() {
672 let mut input = health_run_input();
673 input.complexity = true;
674 input.ownership = true;
675
676 let run = derive_health_run_options(input);
677
678 assert!(!run.sections.hotspots);
679 assert!(!run.ownership);
680
681 let mut input = health_run_input();
682 input.ownership = true;
683 input.hotspots = true;
684
685 let run = derive_health_run_options(input);
686
687 assert!(run.sections.hotspots);
688 assert!(run.ownership);
689 }
690
691 #[test]
692 fn health_run_options_score_gate_forces_score() {
693 let mut input = health_run_input();
694 input.gates.min_score = Some(90.0);
695
696 let run = derive_health_run_options(input);
697
698 assert!(run.sections.score);
699 assert_eq!(run.gates.min_score, Some(90.0));
700 }
701
702 #[test]
703 fn coverage_root_accepts_posix_absolute() {
704 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
705 assert!(
706 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
707 );
708 }
709
710 #[test]
711 fn coverage_root_rejects_relative() {
712 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
713 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
714 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
715 }
716
717 #[test]
718 fn coverage_root_accepts_none() {
719 assert!(validate_coverage_root_absolute(None).is_ok());
720 }
721
722 #[test]
723 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
724 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
725 }
726}