1use std::path::{Path, PathBuf};
4
5use fallow_config::{EmailMode, WorkspaceInfo};
6use fallow_output::{
7 DiffIndex, EffortEstimate, FindingSeverity, GroupByMode, RuntimeCoverageReport,
8 RuntimeCoverageWatermark,
9};
10use fallow_types::output_format::OutputFormat;
11use fallow_types::path_util::is_absolute_path_any_platform;
12use fallow_types::results::AnalysisResults;
13use rustc_hash::{FxHashMap, FxHashSet};
14
15use crate::module_graph::RetainedModuleGraph;
16use crate::results::DeadCodeAnalysisArtifacts;
17
18mod actions;
19mod analysis_data;
20mod assembly;
21mod baseline_io;
22mod branching;
23pub use branching::{BranchingByFile, branching_by_file};
24mod churn_file;
25mod component_rollup;
26mod core_pipeline;
27mod coverage_gaps;
28mod coverage_intelligence;
29mod coverage_settings;
30mod coverage_v8;
31mod css_analytics;
32mod derived_sections;
33pub(crate) mod diagnostics;
34mod execute;
35mod file_scores;
36mod filters;
37mod finding_sort;
38mod findings;
39mod findings_pipeline;
40mod framework_health;
41mod grouping;
42mod health_error;
43mod hotspots;
44mod ignore;
45mod inline;
46pub use inline::{InlineComplexity, inline_complexity};
47mod large_functions;
48mod output_build;
49pub mod ownership;
50mod package_json;
51mod pipeline;
52mod react_hooks;
53mod result;
54mod runner;
55mod runtime_filter;
56mod runtime_sections;
57mod scope;
58pub mod scoring;
61pub mod styling_score;
62mod tailwind_theme;
63mod targets;
64mod threshold_overrides;
65mod timings;
66mod vital_data;
67mod vital_signs_scope;
68
69pub use crate::results::HealthAnalysisResult;
70pub use churn_file::validate_health_churn_file;
71pub use css_analytics::StylingAnalysisArtifacts;
72use derived_sections::{
73 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
74};
75use execute::HealthOptions;
76pub use execute::execute_health_inner;
77use file_scores::{
78 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
79 print_slow_churn_note,
80};
81use finding_sort::sort_findings;
82pub use health_error::HealthError;
83pub use hotspots::{
84 TargetChurnEvidence, TargetChurnOptions, TargetChurnOutcome, analyze_target_churn,
85};
86pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
87pub use runner::{
88 run_ungrouped_health, run_ungrouped_health_with_session,
89 run_ungrouped_health_with_session_artifacts,
90};
91use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
92use vital_signs_scope::{
93 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
94 compute_vital_signs_and_counts,
95};
96
97pub(crate) fn build_styling_analysis_artifacts(
98 files: &[crate::discover::DiscoveredFile],
99 modules: &[crate::source::ModuleInfo],
100 config: &fallow_config::ResolvedConfig,
101) -> StylingAnalysisArtifacts {
102 css_analytics::build_styling_analysis_artifacts(files, modules, config)
103}
104
105#[must_use]
107pub fn shared_parse_data_from_artifacts(
108 results: &AnalysisResults,
109 graph: Option<RetainedModuleGraph>,
110 modules: Option<Vec<crate::source::ModuleInfo>>,
111 files: Option<Vec<crate::discover::DiscoveredFile>>,
112 workspaces: Vec<WorkspaceInfo>,
113 script_used_packages: impl IntoIterator<Item = String>,
114) -> Option<HealthSharedParseData> {
115 let (Some(modules), Some(files)) = (modules, files) else {
116 return None;
117 };
118 let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
119 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
120 results: results.clone(),
121 timings: None,
122 graph: Some(graph),
123 modules: None,
124 files: None,
125 script_used_packages: script_used_packages.clone(),
126 trace_provenance: crate::trace::TraceProvenance::default(),
127 file_hashes: FxHashMap::default(),
128 });
129 Some(HealthSharedParseData {
130 files,
131 modules,
132 dead_code_results: Some(results.clone()),
133 workspaces,
134 analysis_output,
135 })
136}
137
138#[must_use]
144pub fn should_precompute_dead_code_analysis(
145 options: &HealthExecutionOptions<'_>,
146 config: &fallow_config::ResolvedConfig,
147) -> bool {
148 let max_crap = options
149 .thresholds
150 .max_crap
151 .unwrap_or(config.health.max_crap);
152 options.file_scores
153 || options.coverage_gaps
154 || options.config_activates_coverage_gaps
155 || options.hotspots
156 || options.targets
157 || options.force_full
158 || max_crap > 0.0
159 || options.runtime_coverage.is_some()
160}
161
162pub trait HealthGroupResolver {
168 fn mode_label(&self) -> &'static str;
170 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
172 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
174}
175
176#[derive(Debug, Clone, Copy)]
179pub enum NoGroupResolver {}
180
181#[expect(
182 clippy::uninhabited_references,
183 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
184)]
185impl HealthGroupResolver for NoGroupResolver {
186 fn mode_label(&self) -> &'static str {
187 match *self {}
188 }
189 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
190 match *self {}
191 }
192 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
193 match *self {}
194 }
195}
196
197pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
209 + 'a;
210
211pub struct RuntimeCoverageSeamInput<'a> {
213 pub root: &'a Path,
215 pub modules: &'a [fallow_types::extract::ModuleInfo],
217 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
220 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
222 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
224 pub ignore_set: &'a globset::GlobSet,
226 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
228 pub ws_roots: Option<&'a [PathBuf]>,
230 pub top: Option<usize>,
232 pub codeowners_path: Option<&'a str>,
234 pub quiet: bool,
236 pub output: OutputFormat,
238}
239
240pub struct HealthSeams<'a> {
244 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
246 pub note_graph_structure: &'a dyn Fn(usize, usize),
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum HealthSort {
255 Severity,
258 Cyclomatic,
260 Cognitive,
262 Lines,
264}
265
266#[derive(Debug, Clone, Copy, Default, PartialEq)]
268pub struct HealthThresholdOverrides {
269 pub max_cyclomatic: Option<u16>,
271 pub max_cognitive: Option<u16>,
273 pub max_crap: Option<f64>,
276}
277
278#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
280pub struct HealthCoverageInputs<'a> {
281 pub coverage: Option<&'a Path>,
283 pub coverage_root: Option<&'a Path>,
286 pub coverage_relocated: bool,
292}
293
294pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
301 if let Some(path) = coverage_root
302 && !is_absolute_path_any_platform(path)
303 {
304 return Err(format!(
305 "--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'.",
306 path.display()
307 ));
308 }
309 Ok(())
310}
311
312#[derive(Debug, Clone, Copy, Default, PartialEq)]
314pub struct HealthGateOptions {
315 pub min_score: Option<f64>,
317 pub min_severity: Option<FindingSeverity>,
319 pub report_only: bool,
321 pub fail_on_stale_baseline: bool,
323 pub fail_on_parse_error: bool,
327 pub fail_on_issues: bool,
331}
332
333#[derive(Debug, Clone)]
335pub struct HealthSectionOptions {
336 output: OutputFormat,
337 complexity: bool,
338 file_scores: bool,
339 coverage_gaps: bool,
340 hotspots: bool,
341 targets: bool,
342 css: bool,
343 score: bool,
344 score_gate: bool,
345 snapshot_requested: bool,
346 trend: bool,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub struct DerivedHealthSections {
352 pub any_section: bool,
355 pub complexity: bool,
357 pub file_scores: bool,
359 pub coverage_gaps: bool,
361 pub hotspots: bool,
363 pub targets: bool,
365 pub css: bool,
367 pub score: bool,
369 pub force_full: bool,
372 pub score_only_output: bool,
375}
376
377#[derive(Debug, Clone)]
380pub struct HealthRunOptionsInput<'a> {
381 pub output: OutputFormat,
383 pub thresholds: HealthThresholdOverrides,
385 pub top: Option<usize>,
387 pub sort: HealthSort,
389 pub complexity: bool,
391 pub file_scores: bool,
393 pub coverage_gaps: bool,
395 pub hotspots: bool,
397 pub ownership: bool,
399 pub ownership_emails: Option<EmailMode>,
401 pub targets: bool,
403 pub css: bool,
405 pub effort: Option<EffortEstimate>,
407 pub score: bool,
409 pub gates: HealthGateOptions,
411 pub snapshot_requested: bool,
413 pub trend: bool,
415 pub since: Option<&'a str>,
417 pub min_commits: Option<u32>,
419 pub coverage_inputs: HealthCoverageInputs<'a>,
421 pub runtime_coverage: Option<RuntimeCoverageOptions>,
423}
424
425#[derive(Debug, Clone)]
427pub struct HealthRunOptions<'a> {
428 pub thresholds: HealthThresholdOverrides,
430 pub top: Option<usize>,
432 pub sort: HealthSort,
434 pub sections: DerivedHealthSections,
436 pub ownership: bool,
438 pub ownership_emails: Option<EmailMode>,
440 pub effort: Option<EffortEstimate>,
442 pub gates: HealthGateOptions,
444 pub since: Option<&'a str>,
446 pub min_commits: Option<u32>,
448 pub coverage_inputs: HealthCoverageInputs<'a>,
450 pub runtime_coverage: Option<RuntimeCoverageOptions>,
452}
453
454#[derive(Debug, Clone)]
458pub struct HealthExecutionOptions<'a> {
459 pub root: &'a Path,
461 pub config_path: &'a Option<PathBuf>,
463 pub output: OutputFormat,
465 pub no_cache: bool,
467 pub threads: usize,
469 pub quiet: bool,
471 pub complexity_breakdown: bool,
476 pub thresholds: HealthThresholdOverrides,
478 pub top: Option<usize>,
480 pub sort: HealthSort,
482 pub production: bool,
485 pub production_override: Option<bool>,
488 pub allow_remote_extends: bool,
490 pub changed_since: Option<&'a str>,
492 pub diff_index: Option<&'a DiffIndex>,
494 pub use_shared_diff_index: bool,
497 pub workspace: Option<&'a [String]>,
499 pub changed_workspaces: Option<&'a str>,
501 pub scope: Option<PathBuf>,
506 pub baseline: Option<&'a Path>,
508 pub save_baseline: Option<&'a Path>,
510 pub baseline_mode: crate::baseline::HealthBaselineMode,
515 pub baseline_mode_explicit: bool,
521 pub complexity: bool,
523 pub file_scores: bool,
525 pub coverage_gaps: bool,
527 pub config_activates_coverage_gaps: bool,
530 pub hotspots: bool,
532 pub ownership: bool,
534 pub ownership_emails: Option<EmailMode>,
536 pub targets: bool,
538 pub css: bool,
540 pub css_deep: bool,
542 pub force_full: bool,
545 pub score_only_output: bool,
548 pub enforce_coverage_gap_gate: bool,
550 pub effort: Option<EffortEstimate>,
552 pub score: bool,
554 pub gates: HealthGateOptions,
556 pub since: Option<&'a str>,
558 pub min_commits: Option<u32>,
560 pub explain: bool,
562 pub summary: bool,
564 pub save_snapshot: Option<PathBuf>,
566 pub trend: bool,
568 pub coverage_inputs: HealthCoverageInputs<'a>,
570 pub performance: bool,
572 pub runtime_coverage: Option<RuntimeCoverageOptions>,
574 pub churn_file: Option<&'a Path>,
576 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
578 pub group_by: Option<GroupByMode>,
580}
581
582#[must_use]
584fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
585 let score = options.score
586 || options.score_gate
587 || options.trend
588 || matches!(options.output, OutputFormat::Badge);
589 let any_section = options.complexity
590 || options.file_scores
591 || options.coverage_gaps
592 || options.hotspots
593 || options.targets
594 || score;
595 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
596 let force_full = options.snapshot_requested || effective_score;
597
598 DerivedHealthSections {
599 any_section,
600 complexity: if any_section {
601 options.complexity
602 } else {
603 true
604 },
605 file_scores: if any_section {
606 options.file_scores
607 } else {
608 true
609 } || force_full,
610 coverage_gaps: if any_section {
611 options.coverage_gaps
612 } else {
613 false
614 },
615 hotspots: if any_section { options.hotspots } else { true }
616 || options.snapshot_requested
617 || options.trend,
618 targets: if any_section { options.targets } else { true },
619 css: options.css,
620 score: effective_score,
621 force_full,
622 score_only_output: is_health_score_only_output(options, score),
623 }
624}
625
626#[must_use]
628pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
629 let targets = input.targets || input.effort.is_some();
630 let sections = derive_health_sections(&HealthSectionOptions {
631 output: input.output,
632 complexity: input.complexity,
633 file_scores: input.file_scores,
634 coverage_gaps: input.coverage_gaps,
635 hotspots: input.hotspots,
636 targets,
637 css: input.css,
638 score: input.score,
639 score_gate: input.gates.min_score.is_some(),
640 snapshot_requested: input.snapshot_requested,
641 trend: input.trend,
642 });
643
644 HealthRunOptions {
645 thresholds: input.thresholds,
646 top: input.top,
647 sort: input.sort,
648 sections,
649 ownership: input.ownership && sections.hotspots,
650 ownership_emails: input.ownership_emails,
651 effort: input.effort,
652 gates: input.gates,
653 since: input.since,
654 min_commits: input.min_commits,
655 coverage_inputs: input.coverage_inputs,
656 runtime_coverage: input.runtime_coverage,
657 }
658}
659
660fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
661 score
662 && !options.complexity
663 && !options.file_scores
664 && !options.coverage_gaps
665 && !options.hotspots
666 && !options.targets
667 && !options.trend
668}
669
670#[derive(Debug, Clone)]
672pub struct ComplexitySectionOptions {
673 complexity: bool,
674 file_scores: bool,
675 coverage_gaps: bool,
676 hotspots: bool,
677 ownership: bool,
678 targets: bool,
679 css: bool,
680 score: bool,
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
685pub struct DerivedComplexityOptions {
686 any_section: bool,
687 complexity: bool,
688 file_scores: bool,
689 coverage_gaps: bool,
690 hotspots: bool,
691 ownership: bool,
692 targets: bool,
693 force_full: bool,
694 score_only_output: bool,
695 score: bool,
696}
697
698#[must_use]
700pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
701 let requested_hotspots = options.hotspots || options.ownership;
702 let sections = derive_health_sections(&HealthSectionOptions {
703 output: OutputFormat::Human,
704 complexity: options.complexity,
705 file_scores: options.file_scores,
706 coverage_gaps: options.coverage_gaps,
707 hotspots: requested_hotspots,
708 targets: options.targets,
709 css: options.css,
710 score: options.score,
711 score_gate: false,
712 snapshot_requested: false,
713 trend: false,
714 });
715
716 DerivedComplexityOptions {
717 any_section: sections.any_section,
718 complexity: sections.complexity,
719 file_scores: sections.file_scores,
720 coverage_gaps: sections.coverage_gaps,
721 hotspots: sections.hotspots,
722 ownership: options.ownership && sections.hotspots,
723 targets: sections.targets,
724 force_full: sections.force_full,
725 score_only_output: sections.score_only_output,
726 score: sections.score,
727 }
728}
729
730#[derive(Debug, Clone, PartialEq)]
733pub struct ComplexityRunOptions<'a> {
734 thresholds: HealthThresholdOverrides,
735 top: Option<usize>,
736 sort: HealthSort,
737 complexity_breakdown: bool,
738 sections: DerivedComplexityOptions,
739 ownership_emails: Option<EmailMode>,
740 effort: Option<EffortEstimate>,
741 css: bool,
742 since: Option<&'a str>,
743 min_commits: Option<u32>,
744 coverage_inputs: HealthCoverageInputs<'a>,
745}
746
747#[derive(Debug, Clone)]
749pub struct RuntimeCoverageOptions {
750 pub path: PathBuf,
752 pub min_invocations_hot: u64,
754 pub min_observation_volume: Option<u32>,
759 pub low_traffic_threshold: Option<f64>,
763 pub license_jwt: String,
765 pub watermark: Option<RuntimeCoverageWatermark>,
767}
768
769pub struct HealthSharedParseData {
771 pub files: Vec<fallow_types::discover::DiscoveredFile>,
773 pub modules: Vec<fallow_types::extract::ModuleInfo>,
775 pub dead_code_results: Option<AnalysisResults>,
777 pub workspaces: Vec<WorkspaceInfo>,
779 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 fn health_run_input() -> HealthRunOptionsInput<'static> {
788 HealthRunOptionsInput {
789 output: OutputFormat::Json,
790 thresholds: HealthThresholdOverrides::default(),
791 top: None,
792 sort: HealthSort::Cyclomatic,
793 complexity: false,
794 file_scores: false,
795 coverage_gaps: false,
796 hotspots: false,
797 ownership: false,
798 ownership_emails: None,
799 targets: false,
800 css: false,
801 effort: None,
802 score: false,
803 gates: HealthGateOptions::default(),
804 snapshot_requested: false,
805 trend: false,
806 since: None,
807 min_commits: None,
808 coverage_inputs: HealthCoverageInputs::default(),
809 runtime_coverage: None,
810 }
811 }
812
813 #[test]
814 fn health_execution_options_own_shared_runner_scope() {
815 let root = Path::new("/project");
816 let config_path = None;
817 let workspace = vec!["packages/app".to_string()];
818 let diff = DiffIndex::from_unified_diff(
819 "diff --git a/src/a.ts b/src/a.ts\n\
820 --- a/src/a.ts\n\
821 +++ b/src/a.ts\n\
822 @@ -0,0 +1,1 @@\n\
823 +new line\n",
824 );
825 let runtime_coverage = RuntimeCoverageOptions {
826 path: PathBuf::from("coverage/v8"),
827 min_invocations_hot: 10,
828 min_observation_volume: Some(500),
829 low_traffic_threshold: Some(0.01),
830 license_jwt: "test.jwt".to_string(),
831 watermark: None,
832 };
833
834 let options = HealthExecutionOptions {
835 root,
836 config_path: &config_path,
837 output: OutputFormat::Json,
838 no_cache: true,
839 threads: 2,
840 quiet: true,
841 complexity_breakdown: true,
842 thresholds: HealthThresholdOverrides::default(),
843 top: Some(5),
844 sort: HealthSort::Cognitive,
845 production: true,
846 production_override: Some(true),
847 allow_remote_extends: false,
848 changed_since: Some("HEAD~1"),
849 diff_index: Some(&diff),
850 use_shared_diff_index: false,
851 workspace: Some(&workspace),
852 changed_workspaces: None,
853 scope: None,
854 baseline: Some(Path::new(".fallow/health-baseline.json")),
855 save_baseline: None,
856 baseline_mode: crate::baseline::HealthBaselineMode::Count,
857 baseline_mode_explicit: false,
858 complexity: true,
859 file_scores: true,
860 coverage_gaps: false,
861 config_activates_coverage_gaps: false,
862 hotspots: true,
863 ownership: false,
864 ownership_emails: None,
865 targets: true,
866 css: false,
867 css_deep: false,
868 force_full: true,
869 score_only_output: false,
870 enforce_coverage_gap_gate: true,
871 effort: Some(EffortEstimate::Low),
872 score: true,
873 gates: HealthGateOptions {
874 min_score: Some(80.0),
875 min_severity: None,
876 report_only: false,
877 fail_on_stale_baseline: false,
878 fail_on_parse_error: false,
879 fail_on_issues: false,
880 },
881 since: Some("30d"),
882 min_commits: Some(2),
883 explain: true,
884 summary: false,
885 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
886 trend: true,
887 coverage_inputs: HealthCoverageInputs::default(),
888 performance: true,
889 runtime_coverage: Some(runtime_coverage),
890 churn_file: Some(Path::new("churn.json")),
891 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
892 group_by: Some(GroupByMode::Directory),
893 };
894
895 assert_eq!(options.root, root);
896 assert!(
897 options
898 .diff_index
899 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
900 );
901 assert_eq!(options.workspace, Some(workspace.as_slice()));
902 assert!(options.runtime_coverage.is_some());
903 assert_eq!(options.group_by, Some(GroupByMode::Directory));
904 assert_eq!(
905 options.save_snapshot.as_deref(),
906 Some(Path::new(".fallow/snapshots/health.json"))
907 );
908 }
909
910 #[test]
911 fn health_run_options_default_sections_match_health_defaults() {
912 let run = derive_health_run_options(health_run_input());
913
914 assert!(run.sections.complexity);
915 assert!(run.sections.file_scores);
916 assert!(run.sections.hotspots);
917 assert!(run.sections.targets);
918 assert!(run.sections.score);
919 assert!(!run.ownership);
920 }
921
922 #[test]
923 fn health_run_options_effort_requests_targets() {
924 let mut input = health_run_input();
925 input.effort = Some(EffortEstimate::Low);
926
927 let run = derive_health_run_options(input);
928
929 assert!(run.sections.targets);
930 assert_eq!(run.effort, Some(EffortEstimate::Low));
931 }
932
933 struct HealthExecutionOptionsFixture {
934 config_path: Option<PathBuf>,
935 }
936
937 impl HealthExecutionOptionsFixture {
938 const fn new() -> Self {
939 Self { config_path: None }
940 }
941
942 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
943 HealthExecutionOptions {
944 root,
945 config_path: &self.config_path,
946 output: OutputFormat::Human,
947 no_cache: true,
948 threads: 1,
949 quiet: true,
950 complexity_breakdown: false,
951 thresholds: HealthThresholdOverrides::default(),
952 top: None,
953 sort: HealthSort::Cyclomatic,
954 production: false,
955 production_override: None,
956 allow_remote_extends: false,
957 changed_since: None,
958 diff_index: None,
959 use_shared_diff_index: false,
960 workspace: None,
961 changed_workspaces: None,
962 scope: None,
963 baseline: None,
964 save_baseline: None,
965 baseline_mode: crate::baseline::HealthBaselineMode::Count,
966 baseline_mode_explicit: false,
967 complexity: true,
968 file_scores: false,
969 coverage_gaps: false,
970 config_activates_coverage_gaps: false,
971 hotspots: false,
972 ownership: false,
973 ownership_emails: None,
974 targets: false,
975 css: false,
976 css_deep: false,
977 force_full: false,
978 score_only_output: false,
979 enforce_coverage_gap_gate: true,
980 effort: None,
981 score: false,
982 gates: HealthGateOptions::default(),
983 since: None,
984 min_commits: None,
985 explain: false,
986 summary: false,
987 save_snapshot: None,
988 trend: false,
989 coverage_inputs: HealthCoverageInputs::default(),
990 performance: false,
991 runtime_coverage: None,
992 churn_file: None,
993 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
994 group_by: None,
995 }
996 }
997 }
998
999 #[test]
1006 fn a_churn_file_that_fails_the_re_read_records_the_skip() {
1007 let project = tempfile::tempdir().expect("temp dir");
1008 let root = project.path();
1009 let churn_file = root.join("churn.json");
1010 std::fs::write(&churn_file, "{ not json").expect("churn file");
1011 let fixture = HealthExecutionOptionsFixture::new();
1012 let mut options = fixture.options(root);
1013 options.churn_file = Some(&churn_file);
1014
1015 assert!(
1016 hotspots::fetch_churn_data(&options, &root.join(".fallow")).is_none(),
1017 "an unreadable churn file yields no churn"
1018 );
1019
1020 let recorded = fallow_config::health_stage_workspace_diagnostics(root);
1021 let skip = recorded
1022 .iter()
1023 .find(|entry| entry.kind.id() == "hotspots-skipped")
1024 .expect("the skip is recorded");
1025 assert_eq!(
1026 skip.kind,
1027 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
1028 cause: "churn-file-unreadable".to_owned(),
1029 }
1030 );
1031 assert!(
1032 skip.message.contains("churn.json"),
1033 "the remedy names the file: {}",
1034 skip.message
1035 );
1036 }
1037
1038 #[test]
1039 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
1040 let project = tempfile::tempdir().expect("temp dir");
1041 let fixture = HealthExecutionOptionsFixture::new();
1042 let options = fixture.options(project.path());
1043 let config = crate::project_config::default_project_config(project.path()).config;
1044
1045 assert!(should_precompute_dead_code_analysis(&options, &config));
1046 }
1047
1048 #[test]
1049 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
1050 let project = tempfile::tempdir().expect("temp dir");
1051 let fixture = HealthExecutionOptionsFixture::new();
1052 let mut options = fixture.options(project.path());
1053 options.thresholds.max_crap = Some(0.0);
1054 let config = crate::project_config::default_project_config(project.path()).config;
1055
1056 assert!(!should_precompute_dead_code_analysis(&options, &config));
1057 }
1058
1059 #[test]
1060 fn standalone_health_precomputes_dead_code_for_target_sections() {
1061 let project = tempfile::tempdir().expect("temp dir");
1062 let fixture = HealthExecutionOptionsFixture::new();
1063 let mut options = fixture.options(project.path());
1064 options.thresholds.max_crap = Some(0.0);
1065 options.targets = true;
1066 let config = crate::project_config::default_project_config(project.path()).config;
1067
1068 assert!(should_precompute_dead_code_analysis(&options, &config));
1069 }
1070
1071 #[test]
1072 fn health_run_options_ownership_requires_hotspots() {
1073 let mut input = health_run_input();
1074 input.complexity = true;
1075 input.ownership = true;
1076
1077 let run = derive_health_run_options(input);
1078
1079 assert!(!run.sections.hotspots);
1080 assert!(!run.ownership);
1081
1082 let mut input = health_run_input();
1083 input.ownership = true;
1084 input.hotspots = true;
1085
1086 let run = derive_health_run_options(input);
1087
1088 assert!(run.sections.hotspots);
1089 assert!(run.ownership);
1090 }
1091
1092 #[test]
1093 fn health_run_options_score_gate_forces_score() {
1094 let mut input = health_run_input();
1095 input.gates.min_score = Some(90.0);
1096
1097 let run = derive_health_run_options(input);
1098
1099 assert!(run.sections.score);
1100 assert_eq!(run.gates.min_score, Some(90.0));
1101 }
1102
1103 #[test]
1104 fn coverage_root_accepts_posix_absolute() {
1105 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1106 assert!(
1107 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1108 );
1109 }
1110
1111 #[test]
1112 fn coverage_root_rejects_relative() {
1113 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1114 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1115 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1116 }
1117
1118 #[test]
1119 fn coverage_root_accepts_none() {
1120 assert!(validate_coverage_root_absolute(None).is_ok());
1121 }
1122
1123 #[test]
1124 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1125 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1126 }
1127}