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 churn_file;
23mod component_rollup;
24mod core_pipeline;
25mod coverage_gaps;
26mod coverage_intelligence;
27mod coverage_settings;
28mod css_analytics;
29mod derived_sections;
30mod execute;
31mod file_scores;
32mod filters;
33mod finding_sort;
34mod findings;
35mod findings_pipeline;
36mod framework_health;
37mod grouping;
38mod health_error;
39mod hotspots;
40mod ignore;
41mod large_functions;
42mod output_build;
43pub mod ownership;
44mod package_json;
45mod pipeline;
46mod react_hooks;
47mod result;
48mod runner;
49mod runtime_filter;
50mod runtime_sections;
51mod scope;
52pub mod scoring;
55pub mod styling_score;
56mod tailwind_theme;
57mod targets;
58mod threshold_overrides;
59mod timings;
60mod vital_data;
61mod vital_signs_scope;
62
63pub use crate::results::HealthAnalysisResult;
64pub use churn_file::validate_health_churn_file;
65pub use css_analytics::StylingAnalysisArtifacts;
66use derived_sections::{
67 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
68};
69use execute::HealthOptions;
70pub use execute::execute_health_inner;
71use file_scores::{
72 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
73 print_slow_churn_note,
74};
75use finding_sort::sort_findings;
76pub use health_error::HealthError;
77pub use hotspots::{
78 TargetChurnEvidence, TargetChurnOptions, TargetChurnOutcome, analyze_target_churn,
79};
80pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
81pub use runner::{
82 run_ungrouped_health, run_ungrouped_health_with_session,
83 run_ungrouped_health_with_session_artifacts,
84};
85use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
86use vital_signs_scope::{
87 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
88 compute_vital_signs_and_counts,
89};
90
91pub(crate) fn build_styling_analysis_artifacts(
92 files: &[crate::discover::DiscoveredFile],
93 config: &fallow_config::ResolvedConfig,
94) -> StylingAnalysisArtifacts {
95 css_analytics::build_styling_analysis_artifacts(files, config)
96}
97
98#[must_use]
100pub fn shared_parse_data_from_artifacts(
101 results: &AnalysisResults,
102 graph: Option<RetainedModuleGraph>,
103 modules: Option<Vec<crate::source::ModuleInfo>>,
104 files: Option<Vec<crate::discover::DiscoveredFile>>,
105 workspaces: Vec<WorkspaceInfo>,
106 script_used_packages: impl IntoIterator<Item = String>,
107) -> Option<HealthSharedParseData> {
108 let (Some(modules), Some(files)) = (modules, files) else {
109 return None;
110 };
111 let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
112 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
113 results: results.clone(),
114 timings: None,
115 graph: Some(graph),
116 modules: None,
117 files: None,
118 script_used_packages: script_used_packages.clone(),
119 file_hashes: FxHashMap::default(),
120 });
121 Some(HealthSharedParseData {
122 files,
123 modules,
124 dead_code_results: Some(results.clone()),
125 workspaces,
126 analysis_output,
127 })
128}
129
130#[must_use]
136pub fn should_precompute_dead_code_analysis(
137 options: &HealthExecutionOptions<'_>,
138 config: &fallow_config::ResolvedConfig,
139) -> bool {
140 let max_crap = options
141 .thresholds
142 .max_crap
143 .unwrap_or(config.health.max_crap);
144 options.file_scores
145 || options.coverage_gaps
146 || options.config_activates_coverage_gaps
147 || options.hotspots
148 || options.targets
149 || options.force_full
150 || max_crap > 0.0
151 || options.runtime_coverage.is_some()
152}
153
154pub trait HealthGroupResolver {
160 fn mode_label(&self) -> &'static str;
162 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
164 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
166}
167
168#[derive(Debug, Clone, Copy)]
171pub enum NoGroupResolver {}
172
173#[expect(
174 clippy::uninhabited_references,
175 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
176)]
177impl HealthGroupResolver for NoGroupResolver {
178 fn mode_label(&self) -> &'static str {
179 match *self {}
180 }
181 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
182 match *self {}
183 }
184 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
185 match *self {}
186 }
187}
188
189pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
201 + 'a;
202
203pub struct RuntimeCoverageSeamInput<'a> {
205 pub root: &'a Path,
207 pub modules: &'a [fallow_types::extract::ModuleInfo],
209 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
212 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
214 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
216 pub ignore_set: &'a globset::GlobSet,
218 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
220 pub ws_roots: Option<&'a [PathBuf]>,
222 pub top: Option<usize>,
224 pub codeowners_path: Option<&'a str>,
226 pub quiet: bool,
228 pub output: OutputFormat,
230}
231
232pub struct HealthSeams<'a> {
236 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
238 pub note_graph_structure: &'a dyn Fn(usize, usize),
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum HealthSort {
247 Severity,
250 Cyclomatic,
252 Cognitive,
254 Lines,
256}
257
258#[derive(Debug, Clone, Copy, Default, PartialEq)]
260pub struct HealthThresholdOverrides {
261 pub max_cyclomatic: Option<u16>,
263 pub max_cognitive: Option<u16>,
265 pub max_crap: Option<f64>,
268}
269
270#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
272pub struct HealthCoverageInputs<'a> {
273 pub coverage: Option<&'a Path>,
275 pub coverage_root: Option<&'a Path>,
278 pub coverage_relocated: bool,
284}
285
286pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
293 if let Some(path) = coverage_root
294 && !is_absolute_path_any_platform(path)
295 {
296 return Err(format!(
297 "--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'.",
298 path.display()
299 ));
300 }
301 Ok(())
302}
303
304#[derive(Debug, Clone, Copy, Default, PartialEq)]
306pub struct HealthGateOptions {
307 pub min_score: Option<f64>,
309 pub min_severity: Option<FindingSeverity>,
311 pub report_only: bool,
313}
314
315#[derive(Debug, Clone)]
317pub struct HealthSectionOptions {
318 output: OutputFormat,
319 complexity: bool,
320 file_scores: bool,
321 coverage_gaps: bool,
322 hotspots: bool,
323 targets: bool,
324 css: bool,
325 score: bool,
326 score_gate: bool,
327 snapshot_requested: bool,
328 trend: bool,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub struct DerivedHealthSections {
334 pub any_section: bool,
337 pub complexity: bool,
339 pub file_scores: bool,
341 pub coverage_gaps: bool,
343 pub hotspots: bool,
345 pub targets: bool,
347 pub css: bool,
349 pub score: bool,
351 pub force_full: bool,
354 pub score_only_output: bool,
357}
358
359#[derive(Debug, Clone)]
362pub struct HealthRunOptionsInput<'a> {
363 pub output: OutputFormat,
365 pub thresholds: HealthThresholdOverrides,
367 pub top: Option<usize>,
369 pub sort: HealthSort,
371 pub complexity: bool,
373 pub file_scores: bool,
375 pub coverage_gaps: bool,
377 pub hotspots: bool,
379 pub ownership: bool,
381 pub ownership_emails: Option<EmailMode>,
383 pub targets: bool,
385 pub css: bool,
387 pub effort: Option<EffortEstimate>,
389 pub score: bool,
391 pub gates: HealthGateOptions,
393 pub snapshot_requested: bool,
395 pub trend: bool,
397 pub since: Option<&'a str>,
399 pub min_commits: Option<u32>,
401 pub coverage_inputs: HealthCoverageInputs<'a>,
403 pub runtime_coverage: Option<RuntimeCoverageOptions>,
405}
406
407#[derive(Debug, Clone)]
409pub struct HealthRunOptions<'a> {
410 pub thresholds: HealthThresholdOverrides,
412 pub top: Option<usize>,
414 pub sort: HealthSort,
416 pub sections: DerivedHealthSections,
418 pub ownership: bool,
420 pub ownership_emails: Option<EmailMode>,
422 pub effort: Option<EffortEstimate>,
424 pub gates: HealthGateOptions,
426 pub since: Option<&'a str>,
428 pub min_commits: Option<u32>,
430 pub coverage_inputs: HealthCoverageInputs<'a>,
432 pub runtime_coverage: Option<RuntimeCoverageOptions>,
434}
435
436#[derive(Debug, Clone)]
440pub struct HealthExecutionOptions<'a> {
441 pub root: &'a Path,
443 pub config_path: &'a Option<PathBuf>,
445 pub output: OutputFormat,
447 pub no_cache: bool,
449 pub threads: usize,
451 pub quiet: bool,
453 pub complexity_breakdown: bool,
458 pub thresholds: HealthThresholdOverrides,
460 pub top: Option<usize>,
462 pub sort: HealthSort,
464 pub production: bool,
467 pub production_override: Option<bool>,
470 pub allow_remote_extends: bool,
472 pub changed_since: Option<&'a str>,
474 pub diff_index: Option<&'a DiffIndex>,
476 pub use_shared_diff_index: bool,
479 pub workspace: Option<&'a [String]>,
481 pub changed_workspaces: Option<&'a str>,
483 pub baseline: Option<&'a Path>,
485 pub save_baseline: Option<&'a Path>,
487 pub baseline_mode: crate::baseline::HealthBaselineMode,
492 pub baseline_mode_explicit: bool,
498 pub complexity: bool,
500 pub file_scores: bool,
502 pub coverage_gaps: bool,
504 pub config_activates_coverage_gaps: bool,
507 pub hotspots: bool,
509 pub ownership: bool,
511 pub ownership_emails: Option<EmailMode>,
513 pub targets: bool,
515 pub css: bool,
517 pub css_deep: bool,
519 pub force_full: bool,
522 pub score_only_output: bool,
525 pub enforce_coverage_gap_gate: bool,
527 pub effort: Option<EffortEstimate>,
529 pub score: bool,
531 pub gates: HealthGateOptions,
533 pub since: Option<&'a str>,
535 pub min_commits: Option<u32>,
537 pub explain: bool,
539 pub summary: bool,
541 pub save_snapshot: Option<PathBuf>,
543 pub trend: bool,
545 pub coverage_inputs: HealthCoverageInputs<'a>,
547 pub performance: bool,
549 pub runtime_coverage: Option<RuntimeCoverageOptions>,
551 pub churn_file: Option<&'a Path>,
553 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
555 pub group_by: Option<GroupByMode>,
557}
558
559#[must_use]
561fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
562 let score = options.score
563 || options.score_gate
564 || options.trend
565 || matches!(options.output, OutputFormat::Badge);
566 let any_section = options.complexity
567 || options.file_scores
568 || options.coverage_gaps
569 || options.hotspots
570 || options.targets
571 || score;
572 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
573 let force_full = options.snapshot_requested || effective_score;
574
575 DerivedHealthSections {
576 any_section,
577 complexity: if any_section {
578 options.complexity
579 } else {
580 true
581 },
582 file_scores: if any_section {
583 options.file_scores
584 } else {
585 true
586 } || force_full,
587 coverage_gaps: if any_section {
588 options.coverage_gaps
589 } else {
590 false
591 },
592 hotspots: if any_section { options.hotspots } else { true }
593 || options.snapshot_requested
594 || options.trend,
595 targets: if any_section { options.targets } else { true },
596 css: options.css,
597 score: effective_score,
598 force_full,
599 score_only_output: is_health_score_only_output(options, score),
600 }
601}
602
603#[must_use]
605pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
606 let targets = input.targets || input.effort.is_some();
607 let sections = derive_health_sections(&HealthSectionOptions {
608 output: input.output,
609 complexity: input.complexity,
610 file_scores: input.file_scores,
611 coverage_gaps: input.coverage_gaps,
612 hotspots: input.hotspots,
613 targets,
614 css: input.css,
615 score: input.score,
616 score_gate: input.gates.min_score.is_some(),
617 snapshot_requested: input.snapshot_requested,
618 trend: input.trend,
619 });
620
621 HealthRunOptions {
622 thresholds: input.thresholds,
623 top: input.top,
624 sort: input.sort,
625 sections,
626 ownership: input.ownership && sections.hotspots,
627 ownership_emails: input.ownership_emails,
628 effort: input.effort,
629 gates: input.gates,
630 since: input.since,
631 min_commits: input.min_commits,
632 coverage_inputs: input.coverage_inputs,
633 runtime_coverage: input.runtime_coverage,
634 }
635}
636
637fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
638 score
639 && !options.complexity
640 && !options.file_scores
641 && !options.coverage_gaps
642 && !options.hotspots
643 && !options.targets
644 && !options.trend
645}
646
647#[derive(Debug, Clone)]
649pub struct ComplexitySectionOptions {
650 complexity: bool,
651 file_scores: bool,
652 coverage_gaps: bool,
653 hotspots: bool,
654 ownership: bool,
655 targets: bool,
656 css: bool,
657 score: bool,
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
662pub struct DerivedComplexityOptions {
663 any_section: bool,
664 complexity: bool,
665 file_scores: bool,
666 coverage_gaps: bool,
667 hotspots: bool,
668 ownership: bool,
669 targets: bool,
670 force_full: bool,
671 score_only_output: bool,
672 score: bool,
673}
674
675#[must_use]
677pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
678 let requested_hotspots = options.hotspots || options.ownership;
679 let sections = derive_health_sections(&HealthSectionOptions {
680 output: OutputFormat::Human,
681 complexity: options.complexity,
682 file_scores: options.file_scores,
683 coverage_gaps: options.coverage_gaps,
684 hotspots: requested_hotspots,
685 targets: options.targets,
686 css: options.css,
687 score: options.score,
688 score_gate: false,
689 snapshot_requested: false,
690 trend: false,
691 });
692
693 DerivedComplexityOptions {
694 any_section: sections.any_section,
695 complexity: sections.complexity,
696 file_scores: sections.file_scores,
697 coverage_gaps: sections.coverage_gaps,
698 hotspots: sections.hotspots,
699 ownership: options.ownership && sections.hotspots,
700 targets: sections.targets,
701 force_full: sections.force_full,
702 score_only_output: sections.score_only_output,
703 score: sections.score,
704 }
705}
706
707#[derive(Debug, Clone, PartialEq)]
710pub struct ComplexityRunOptions<'a> {
711 thresholds: HealthThresholdOverrides,
712 top: Option<usize>,
713 sort: HealthSort,
714 complexity_breakdown: bool,
715 sections: DerivedComplexityOptions,
716 ownership_emails: Option<EmailMode>,
717 effort: Option<EffortEstimate>,
718 css: bool,
719 since: Option<&'a str>,
720 min_commits: Option<u32>,
721 coverage_inputs: HealthCoverageInputs<'a>,
722}
723
724#[derive(Debug, Clone)]
726pub struct RuntimeCoverageOptions {
727 pub path: PathBuf,
729 pub min_invocations_hot: u64,
731 pub min_observation_volume: Option<u32>,
736 pub low_traffic_threshold: Option<f64>,
740 pub license_jwt: String,
742 pub watermark: Option<RuntimeCoverageWatermark>,
744}
745
746pub struct HealthSharedParseData {
748 pub files: Vec<fallow_types::discover::DiscoveredFile>,
750 pub modules: Vec<fallow_types::extract::ModuleInfo>,
752 pub dead_code_results: Option<AnalysisResults>,
754 pub workspaces: Vec<WorkspaceInfo>,
756 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763
764 fn health_run_input() -> HealthRunOptionsInput<'static> {
765 HealthRunOptionsInput {
766 output: OutputFormat::Json,
767 thresholds: HealthThresholdOverrides::default(),
768 top: None,
769 sort: HealthSort::Cyclomatic,
770 complexity: false,
771 file_scores: false,
772 coverage_gaps: false,
773 hotspots: false,
774 ownership: false,
775 ownership_emails: None,
776 targets: false,
777 css: false,
778 effort: None,
779 score: false,
780 gates: HealthGateOptions::default(),
781 snapshot_requested: false,
782 trend: false,
783 since: None,
784 min_commits: None,
785 coverage_inputs: HealthCoverageInputs::default(),
786 runtime_coverage: None,
787 }
788 }
789
790 #[test]
791 fn health_execution_options_own_shared_runner_scope() {
792 let root = Path::new("/project");
793 let config_path = None;
794 let workspace = vec!["packages/app".to_string()];
795 let diff = DiffIndex::from_unified_diff(
796 "diff --git a/src/a.ts b/src/a.ts\n\
797 --- a/src/a.ts\n\
798 +++ b/src/a.ts\n\
799 @@ -0,0 +1,1 @@\n\
800 +new line\n",
801 );
802 let runtime_coverage = RuntimeCoverageOptions {
803 path: PathBuf::from("coverage/v8"),
804 min_invocations_hot: 10,
805 min_observation_volume: Some(500),
806 low_traffic_threshold: Some(0.01),
807 license_jwt: "test.jwt".to_string(),
808 watermark: None,
809 };
810
811 let options = HealthExecutionOptions {
812 root,
813 config_path: &config_path,
814 output: OutputFormat::Json,
815 no_cache: true,
816 threads: 2,
817 quiet: true,
818 complexity_breakdown: true,
819 thresholds: HealthThresholdOverrides::default(),
820 top: Some(5),
821 sort: HealthSort::Cognitive,
822 production: true,
823 production_override: Some(true),
824 allow_remote_extends: false,
825 changed_since: Some("HEAD~1"),
826 diff_index: Some(&diff),
827 use_shared_diff_index: false,
828 workspace: Some(&workspace),
829 changed_workspaces: None,
830 baseline: Some(Path::new(".fallow/health-baseline.json")),
831 save_baseline: None,
832 baseline_mode: crate::baseline::HealthBaselineMode::Count,
833 baseline_mode_explicit: false,
834 complexity: true,
835 file_scores: true,
836 coverage_gaps: false,
837 config_activates_coverage_gaps: false,
838 hotspots: true,
839 ownership: false,
840 ownership_emails: None,
841 targets: true,
842 css: false,
843 css_deep: false,
844 force_full: true,
845 score_only_output: false,
846 enforce_coverage_gap_gate: true,
847 effort: Some(EffortEstimate::Low),
848 score: true,
849 gates: HealthGateOptions {
850 min_score: Some(80.0),
851 min_severity: None,
852 report_only: false,
853 },
854 since: Some("30d"),
855 min_commits: Some(2),
856 explain: true,
857 summary: false,
858 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
859 trend: true,
860 coverage_inputs: HealthCoverageInputs::default(),
861 performance: true,
862 runtime_coverage: Some(runtime_coverage),
863 churn_file: Some(Path::new("churn.json")),
864 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
865 group_by: Some(GroupByMode::Directory),
866 };
867
868 assert_eq!(options.root, root);
869 assert!(
870 options
871 .diff_index
872 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
873 );
874 assert_eq!(options.workspace, Some(workspace.as_slice()));
875 assert!(options.runtime_coverage.is_some());
876 assert_eq!(options.group_by, Some(GroupByMode::Directory));
877 assert_eq!(
878 options.save_snapshot.as_deref(),
879 Some(Path::new(".fallow/snapshots/health.json"))
880 );
881 }
882
883 #[test]
884 fn health_run_options_default_sections_match_health_defaults() {
885 let run = derive_health_run_options(health_run_input());
886
887 assert!(run.sections.complexity);
888 assert!(run.sections.file_scores);
889 assert!(run.sections.hotspots);
890 assert!(run.sections.targets);
891 assert!(run.sections.score);
892 assert!(!run.ownership);
893 }
894
895 #[test]
896 fn health_run_options_effort_requests_targets() {
897 let mut input = health_run_input();
898 input.effort = Some(EffortEstimate::Low);
899
900 let run = derive_health_run_options(input);
901
902 assert!(run.sections.targets);
903 assert_eq!(run.effort, Some(EffortEstimate::Low));
904 }
905
906 struct HealthExecutionOptionsFixture {
907 config_path: Option<PathBuf>,
908 }
909
910 impl HealthExecutionOptionsFixture {
911 const fn new() -> Self {
912 Self { config_path: None }
913 }
914
915 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
916 HealthExecutionOptions {
917 root,
918 config_path: &self.config_path,
919 output: OutputFormat::Human,
920 no_cache: true,
921 threads: 1,
922 quiet: true,
923 complexity_breakdown: false,
924 thresholds: HealthThresholdOverrides::default(),
925 top: None,
926 sort: HealthSort::Cyclomatic,
927 production: false,
928 production_override: None,
929 allow_remote_extends: false,
930 changed_since: None,
931 diff_index: None,
932 use_shared_diff_index: false,
933 workspace: None,
934 changed_workspaces: None,
935 baseline: None,
936 save_baseline: None,
937 baseline_mode: crate::baseline::HealthBaselineMode::Count,
938 baseline_mode_explicit: false,
939 complexity: true,
940 file_scores: false,
941 coverage_gaps: false,
942 config_activates_coverage_gaps: false,
943 hotspots: false,
944 ownership: false,
945 ownership_emails: None,
946 targets: false,
947 css: false,
948 css_deep: false,
949 force_full: false,
950 score_only_output: false,
951 enforce_coverage_gap_gate: true,
952 effort: None,
953 score: false,
954 gates: HealthGateOptions::default(),
955 since: None,
956 min_commits: None,
957 explain: false,
958 summary: false,
959 save_snapshot: None,
960 trend: false,
961 coverage_inputs: HealthCoverageInputs::default(),
962 performance: false,
963 runtime_coverage: None,
964 churn_file: None,
965 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
966 group_by: None,
967 }
968 }
969 }
970
971 #[test]
972 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
973 let project = tempfile::tempdir().expect("temp dir");
974 let fixture = HealthExecutionOptionsFixture::new();
975 let options = fixture.options(project.path());
976 let config = crate::project_config::default_project_config(project.path()).config;
977
978 assert!(should_precompute_dead_code_analysis(&options, &config));
979 }
980
981 #[test]
982 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
983 let project = tempfile::tempdir().expect("temp dir");
984 let fixture = HealthExecutionOptionsFixture::new();
985 let mut options = fixture.options(project.path());
986 options.thresholds.max_crap = Some(0.0);
987 let config = crate::project_config::default_project_config(project.path()).config;
988
989 assert!(!should_precompute_dead_code_analysis(&options, &config));
990 }
991
992 #[test]
993 fn standalone_health_precomputes_dead_code_for_target_sections() {
994 let project = tempfile::tempdir().expect("temp dir");
995 let fixture = HealthExecutionOptionsFixture::new();
996 let mut options = fixture.options(project.path());
997 options.thresholds.max_crap = Some(0.0);
998 options.targets = true;
999 let config = crate::project_config::default_project_config(project.path()).config;
1000
1001 assert!(should_precompute_dead_code_analysis(&options, &config));
1002 }
1003
1004 #[test]
1005 fn health_run_options_ownership_requires_hotspots() {
1006 let mut input = health_run_input();
1007 input.complexity = true;
1008 input.ownership = true;
1009
1010 let run = derive_health_run_options(input);
1011
1012 assert!(!run.sections.hotspots);
1013 assert!(!run.ownership);
1014
1015 let mut input = health_run_input();
1016 input.ownership = true;
1017 input.hotspots = true;
1018
1019 let run = derive_health_run_options(input);
1020
1021 assert!(run.sections.hotspots);
1022 assert!(run.ownership);
1023 }
1024
1025 #[test]
1026 fn health_run_options_score_gate_forces_score() {
1027 let mut input = health_run_input();
1028 input.gates.min_score = Some(90.0);
1029
1030 let run = derive_health_run_options(input);
1031
1032 assert!(run.sections.score);
1033 assert_eq!(run.gates.min_score, Some(90.0));
1034 }
1035
1036 #[test]
1037 fn coverage_root_accepts_posix_absolute() {
1038 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1039 assert!(
1040 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1041 );
1042 }
1043
1044 #[test]
1045 fn coverage_root_rejects_relative() {
1046 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1047 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1048 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1049 }
1050
1051 #[test]
1052 fn coverage_root_accepts_none() {
1053 assert!(validate_coverage_root_absolute(None).is_ok());
1054 }
1055
1056 #[test]
1057 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1058 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1059 }
1060}