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 change_coupling_section;
25mod churn_file;
26mod component_rollup;
27mod core_pipeline;
28mod coverage_gaps;
29mod coverage_intelligence;
30mod coverage_settings;
31mod coverage_v8;
32mod css_analytics;
33mod cyclomatic_policy;
34mod derived_sections;
35pub(crate) mod diagnostics;
36mod execute;
37mod file_scores;
38mod filters;
39mod finding_sort;
40mod findings;
41mod findings_pipeline;
42mod framework_health;
43mod grouping;
44pub use grouping::validate_group_filter;
45mod health_error;
46mod hotspots;
47pub(crate) mod ignore;
48mod inline;
49pub use inline::{InlineComplexity, inline_complexity};
50mod large_functions;
51mod output_build;
52pub mod ownership;
53mod package_json;
54mod pipeline;
55mod react_hooks;
56mod result;
57mod runner;
58mod runtime_filter;
59mod runtime_sections;
60mod scope;
61pub mod scoring;
64pub mod styling_score;
65mod tailwind_theme;
66mod targets;
67mod test_reach;
68mod threshold_overrides;
69mod timings;
70mod vital_data;
71mod vital_signs_scope;
72
73pub use crate::results::HealthAnalysisResult;
74pub use churn_file::validate_health_churn_file;
75pub use css_analytics::StylingAnalysisArtifacts;
76use derived_sections::{
77 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
78};
79use execute::HealthOptions;
80pub use execute::execute_health_inner;
81use file_scores::{
82 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
83 print_slow_churn_note,
84};
85use finding_sort::sort_findings;
86pub use health_error::HealthError;
87pub use hotspots::{
88 TargetChurnEvidence, TargetChurnOptions, TargetChurnOutcome, analyze_target_churn,
89};
90pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
91pub use runner::{
92 UngroupedHealthSession, run_ungrouped_health_with_session,
93 run_ungrouped_health_with_session_artifacts,
94};
95pub use test_reach::TestReachGraph;
96use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
97use vital_signs_scope::{
98 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
99 compute_vital_signs_and_counts,
100};
101
102pub(crate) fn build_styling_analysis_artifacts(
103 files: &[crate::discover::DiscoveredFile],
104 modules: &[crate::source::ModuleInfo],
105 config: &fallow_config::ResolvedConfig,
106) -> StylingAnalysisArtifacts {
107 css_analytics::build_styling_analysis_artifacts(files, modules, config)
108}
109
110#[must_use]
112pub fn shared_parse_data_from_artifacts(
113 results: &AnalysisResults,
114 graph: Option<RetainedModuleGraph>,
115 modules: Option<Vec<crate::source::ModuleInfo>>,
116 files: Option<Vec<crate::discover::DiscoveredFile>>,
117 workspaces: Vec<WorkspaceInfo>,
118 script_used_packages: impl IntoIterator<Item = String>,
119) -> Option<HealthSharedParseData> {
120 let (Some(modules), Some(files)) = (modules, files) else {
121 return None;
122 };
123 let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
124 let mut results = results.clone();
128 results.restore_cascade_findings();
129 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
130 results: results.clone(),
131 timings: None,
132 graph: Some(graph),
133 modules: None,
134 files: None,
135 script_used_packages: script_used_packages.clone(),
136 trace_provenance: crate::trace::TraceProvenance::default(),
137 file_hashes: FxHashMap::default(),
138 });
139 Some(HealthSharedParseData {
140 files,
141 modules,
142 dead_code_results: Some(results),
143 workspaces,
144 analysis_output,
145 })
146}
147
148#[must_use]
154pub fn should_precompute_dead_code_analysis(
155 options: &HealthExecutionOptions<'_>,
156 config: &fallow_config::ResolvedConfig,
157) -> bool {
158 let max_crap = options
159 .thresholds
160 .max_crap
161 .unwrap_or(config.health.max_crap);
162 options.file_scores
163 || options.coverage_gaps
164 || options.config_activates_coverage_gaps
165 || options.hotspots
166 || options.targets
167 || options.force_full
168 || max_crap > 0.0
169 || options.runtime_coverage.is_some()
170}
171
172pub trait HealthGroupResolver {
178 fn mode_label(&self) -> &'static str;
180 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
182 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
184}
185
186#[derive(Debug, Clone, Copy)]
189pub enum NoGroupResolver {}
190
191#[expect(
192 clippy::uninhabited_references,
193 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
194)]
195impl HealthGroupResolver for NoGroupResolver {
196 fn mode_label(&self) -> &'static str {
197 match *self {}
198 }
199 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
200 match *self {}
201 }
202 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
203 match *self {}
204 }
205}
206
207pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
219 + 'a;
220
221pub struct RuntimeCoverageSeamInput<'a> {
223 pub root: &'a Path,
225 pub modules: &'a [fallow_types::extract::ModuleInfo],
227 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
230 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
232 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
234 pub ignore_set: &'a globset::GlobSet,
236 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
238 pub ws_roots: Option<&'a [PathBuf]>,
240 pub top: Option<usize>,
242 pub codeowners_path: Option<&'a str>,
244 pub quiet: bool,
246 pub output: OutputFormat,
248 pub test_reach: Option<&'a TestReachGraph>,
251}
252
253pub struct HealthSeams<'a> {
257 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
259 pub note_graph_structure: &'a dyn Fn(usize, usize),
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum HealthSort {
268 Severity,
271 Cyclomatic,
273 Cognitive,
275 Lines,
277}
278
279#[derive(Debug, Clone, Copy, Default, PartialEq)]
281pub struct HealthThresholdOverrides {
282 pub max_cyclomatic: Option<u16>,
284 pub max_cognitive: Option<u16>,
286 pub max_crap: Option<f64>,
289}
290
291#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
293pub struct HealthCoverageInputs<'a> {
294 pub coverage: Option<&'a Path>,
296 pub coverage_root: Option<&'a Path>,
299 pub coverage_relocated: bool,
305}
306
307pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
314 if let Some(path) = coverage_root
315 && !is_absolute_path_any_platform(path)
316 {
317 return Err(format!(
318 "--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'.",
319 path.display()
320 ));
321 }
322 Ok(())
323}
324
325#[derive(Debug, Clone, Copy, Default, PartialEq)]
327pub struct HealthGateOptions {
328 pub min_score: Option<f64>,
330 pub min_severity: Option<FindingSeverity>,
332 pub report_only: bool,
334 pub fail_on_stale_baseline: bool,
336 pub fail_on_parse_error: bool,
340 pub fail_on_issues: bool,
344}
345
346#[derive(Debug, Clone)]
348pub struct HealthSectionOptions {
349 output: OutputFormat,
350 complexity: bool,
351 file_scores: bool,
352 coverage_gaps: bool,
353 hotspots: bool,
354 targets: bool,
355 css: bool,
356 score: bool,
357 score_gate: bool,
358 snapshot_requested: bool,
359 trend: bool,
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub struct DerivedHealthSections {
365 pub any_section: bool,
368 pub complexity: bool,
370 pub file_scores: bool,
372 pub coverage_gaps: bool,
374 pub hotspots: bool,
376 pub targets: bool,
378 pub css: bool,
380 pub score: bool,
382 pub force_full: bool,
385 pub score_only_output: bool,
388}
389
390#[derive(Debug, Clone)]
393pub struct HealthRunOptionsInput<'a> {
394 pub output: OutputFormat,
396 pub thresholds: HealthThresholdOverrides,
398 pub top: Option<usize>,
400 pub sort: HealthSort,
402 pub complexity: bool,
404 pub file_scores: bool,
406 pub coverage_gaps: bool,
408 pub hotspots: bool,
410 pub ownership: bool,
412 pub ownership_emails: Option<EmailMode>,
414 pub targets: bool,
416 pub css: bool,
418 pub effort: Option<EffortEstimate>,
420 pub score: bool,
422 pub gates: HealthGateOptions,
424 pub snapshot_requested: bool,
426 pub trend: bool,
428 pub since: Option<&'a str>,
430 pub min_commits: Option<u32>,
432 pub coverage_inputs: HealthCoverageInputs<'a>,
434 pub runtime_coverage: Option<RuntimeCoverageOptions>,
436}
437
438#[derive(Debug, Clone)]
440pub struct HealthRunOptions<'a> {
441 pub thresholds: HealthThresholdOverrides,
443 pub top: Option<usize>,
445 pub sort: HealthSort,
447 pub sections: DerivedHealthSections,
449 pub ownership: bool,
451 pub ownership_emails: Option<EmailMode>,
453 pub effort: Option<EffortEstimate>,
455 pub gates: HealthGateOptions,
457 pub since: Option<&'a str>,
459 pub min_commits: Option<u32>,
461 pub coverage_inputs: HealthCoverageInputs<'a>,
463 pub runtime_coverage: Option<RuntimeCoverageOptions>,
465}
466
467#[derive(Debug, Clone)]
471pub struct HealthExecutionOptions<'a> {
472 pub root: &'a Path,
474 pub config_path: &'a Option<PathBuf>,
476 pub output: OutputFormat,
478 pub no_cache: bool,
480 pub threads: usize,
482 pub quiet: bool,
484 pub complexity_breakdown: bool,
489 pub thresholds: HealthThresholdOverrides,
491 pub top: Option<usize>,
493 pub sort: HealthSort,
495 pub production: bool,
498 pub production_override: Option<bool>,
501 pub allow_remote_extends: bool,
503 pub changed_since: Option<&'a str>,
505 pub diff_index: Option<&'a DiffIndex>,
507 pub use_shared_diff_index: bool,
510 pub workspace: Option<&'a [String]>,
512 pub changed_workspaces: Option<&'a str>,
514 pub scope: Option<PathBuf>,
519 pub baseline: Option<&'a Path>,
521 pub save_baseline: Option<&'a Path>,
523 pub baseline_mode: crate::baseline::HealthBaselineMode,
528 pub baseline_mode_explicit: bool,
534 pub complexity: bool,
536 pub file_scores: bool,
538 pub coverage_gaps: bool,
540 pub config_activates_coverage_gaps: bool,
543 pub hotspots: bool,
545 pub ownership: bool,
547 pub ownership_emails: Option<EmailMode>,
549 pub targets: bool,
551 pub css: bool,
553 pub css_deep: bool,
555 pub force_full: bool,
558 pub score_only_output: bool,
561 pub enforce_coverage_gap_gate: bool,
563 pub effort: Option<EffortEstimate>,
565 pub score: bool,
567 pub gates: HealthGateOptions,
569 pub since: Option<&'a str>,
571 pub min_commits: Option<u32>,
573 pub explain: bool,
575 pub summary: bool,
577 pub save_snapshot: Option<PathBuf>,
579 pub trend: bool,
581 pub coverage_inputs: HealthCoverageInputs<'a>,
583 pub performance: bool,
585 pub runtime_coverage: Option<RuntimeCoverageOptions>,
587 pub churn_file: Option<&'a Path>,
589 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
591 pub group_by: Option<GroupByMode>,
593 pub group_filter: Option<&'a [String]>,
596 pub trend_from: Option<&'a Path>,
599}
600
601#[must_use]
603fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
604 let score = options.score
605 || options.score_gate
606 || options.trend
607 || matches!(options.output, OutputFormat::Badge);
608 let any_section = options.complexity
609 || options.file_scores
610 || options.coverage_gaps
611 || options.hotspots
612 || options.targets
613 || score;
614 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
615 let force_full = options.snapshot_requested || effective_score;
616
617 DerivedHealthSections {
618 any_section,
619 complexity: if any_section {
620 options.complexity
621 } else {
622 true
623 },
624 file_scores: if any_section {
625 options.file_scores
626 } else {
627 true
628 } || force_full,
629 coverage_gaps: if any_section {
630 options.coverage_gaps
631 } else {
632 false
633 },
634 hotspots: if any_section { options.hotspots } else { true }
635 || options.snapshot_requested
636 || options.trend,
637 targets: if any_section { options.targets } else { true },
638 css: options.css,
639 score: effective_score,
640 force_full,
641 score_only_output: is_health_score_only_output(options, score),
642 }
643}
644
645#[must_use]
647pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
648 let targets = input.targets || input.effort.is_some();
649 let sections = derive_health_sections(&HealthSectionOptions {
650 output: input.output,
651 complexity: input.complexity,
652 file_scores: input.file_scores,
653 coverage_gaps: input.coverage_gaps,
654 hotspots: input.hotspots,
655 targets,
656 css: input.css,
657 score: input.score,
658 score_gate: input.gates.min_score.is_some(),
659 snapshot_requested: input.snapshot_requested,
660 trend: input.trend,
661 });
662
663 HealthRunOptions {
664 thresholds: input.thresholds,
665 top: input.top,
666 sort: input.sort,
667 sections,
668 ownership: input.ownership && sections.hotspots,
669 ownership_emails: input.ownership_emails,
670 effort: input.effort,
671 gates: input.gates,
672 since: input.since,
673 min_commits: input.min_commits,
674 coverage_inputs: input.coverage_inputs,
675 runtime_coverage: input.runtime_coverage,
676 }
677}
678
679fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
680 score
681 && !options.complexity
682 && !options.file_scores
683 && !options.coverage_gaps
684 && !options.hotspots
685 && !options.targets
686 && !options.trend
687}
688
689#[derive(Debug, Clone)]
691pub struct ComplexitySectionOptions {
692 complexity: bool,
693 file_scores: bool,
694 coverage_gaps: bool,
695 hotspots: bool,
696 ownership: bool,
697 targets: bool,
698 css: bool,
699 score: bool,
700}
701
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
704pub struct DerivedComplexityOptions {
705 any_section: bool,
706 complexity: bool,
707 file_scores: bool,
708 coverage_gaps: bool,
709 hotspots: bool,
710 ownership: bool,
711 targets: bool,
712 force_full: bool,
713 score_only_output: bool,
714 score: bool,
715}
716
717#[must_use]
719pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
720 let requested_hotspots = options.hotspots || options.ownership;
721 let sections = derive_health_sections(&HealthSectionOptions {
722 output: OutputFormat::Human,
723 complexity: options.complexity,
724 file_scores: options.file_scores,
725 coverage_gaps: options.coverage_gaps,
726 hotspots: requested_hotspots,
727 targets: options.targets,
728 css: options.css,
729 score: options.score,
730 score_gate: false,
731 snapshot_requested: false,
732 trend: false,
733 });
734
735 DerivedComplexityOptions {
736 any_section: sections.any_section,
737 complexity: sections.complexity,
738 file_scores: sections.file_scores,
739 coverage_gaps: sections.coverage_gaps,
740 hotspots: sections.hotspots,
741 ownership: options.ownership && sections.hotspots,
742 targets: sections.targets,
743 force_full: sections.force_full,
744 score_only_output: sections.score_only_output,
745 score: sections.score,
746 }
747}
748
749#[derive(Debug, Clone, PartialEq)]
752pub struct ComplexityRunOptions<'a> {
753 thresholds: HealthThresholdOverrides,
754 top: Option<usize>,
755 sort: HealthSort,
756 complexity_breakdown: bool,
757 sections: DerivedComplexityOptions,
758 ownership_emails: Option<EmailMode>,
759 effort: Option<EffortEstimate>,
760 css: bool,
761 since: Option<&'a str>,
762 min_commits: Option<u32>,
763 coverage_inputs: HealthCoverageInputs<'a>,
764}
765
766#[derive(Debug, Clone)]
768pub struct RuntimeCoverageOptions {
769 pub path: PathBuf,
771 pub min_invocations_hot: u64,
773 pub min_observation_volume: Option<u32>,
778 pub low_traffic_threshold: Option<f64>,
782 pub license_jwt: String,
784 pub watermark: Option<RuntimeCoverageWatermark>,
786}
787
788pub struct HealthSharedParseData {
790 pub files: Vec<fallow_types::discover::DiscoveredFile>,
792 pub modules: Vec<fallow_types::extract::ModuleInfo>,
794 pub dead_code_results: Option<AnalysisResults>,
796 pub workspaces: Vec<WorkspaceInfo>,
798 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 fn health_run_input() -> HealthRunOptionsInput<'static> {
807 HealthRunOptionsInput {
808 output: OutputFormat::Json,
809 thresholds: HealthThresholdOverrides::default(),
810 top: None,
811 sort: HealthSort::Cyclomatic,
812 complexity: false,
813 file_scores: false,
814 coverage_gaps: false,
815 hotspots: false,
816 ownership: false,
817 ownership_emails: None,
818 targets: false,
819 css: false,
820 effort: None,
821 score: false,
822 gates: HealthGateOptions::default(),
823 snapshot_requested: false,
824 trend: false,
825 since: None,
826 min_commits: None,
827 coverage_inputs: HealthCoverageInputs::default(),
828 runtime_coverage: None,
829 }
830 }
831
832 #[test]
833 fn health_execution_options_own_shared_runner_scope() {
834 let root = Path::new("/project");
835 let config_path = None;
836 let workspace = vec!["packages/app".to_string()];
837 let diff = DiffIndex::from_unified_diff(
838 "diff --git a/src/a.ts b/src/a.ts\n\
839 --- a/src/a.ts\n\
840 +++ b/src/a.ts\n\
841 @@ -0,0 +1,1 @@\n\
842 +new line\n",
843 );
844 let runtime_coverage = RuntimeCoverageOptions {
845 path: PathBuf::from("coverage/v8"),
846 min_invocations_hot: 10,
847 min_observation_volume: Some(500),
848 low_traffic_threshold: Some(0.01),
849 license_jwt: "test.jwt".to_string(),
850 watermark: None,
851 };
852
853 let options = HealthExecutionOptions {
854 root,
855 config_path: &config_path,
856 output: OutputFormat::Json,
857 no_cache: true,
858 threads: 2,
859 quiet: true,
860 complexity_breakdown: true,
861 thresholds: HealthThresholdOverrides::default(),
862 top: Some(5),
863 sort: HealthSort::Cognitive,
864 production: true,
865 production_override: Some(true),
866 allow_remote_extends: false,
867 changed_since: Some("HEAD~1"),
868 diff_index: Some(&diff),
869 use_shared_diff_index: false,
870 workspace: Some(&workspace),
871 changed_workspaces: None,
872 scope: None,
873 baseline: Some(Path::new(".fallow/health-baseline.json")),
874 save_baseline: None,
875 baseline_mode: crate::baseline::HealthBaselineMode::Count,
876 baseline_mode_explicit: false,
877 complexity: true,
878 file_scores: true,
879 coverage_gaps: false,
880 config_activates_coverage_gaps: false,
881 hotspots: true,
882 ownership: false,
883 ownership_emails: None,
884 targets: true,
885 css: false,
886 css_deep: false,
887 force_full: true,
888 score_only_output: false,
889 enforce_coverage_gap_gate: true,
890 effort: Some(EffortEstimate::Low),
891 score: true,
892 gates: HealthGateOptions {
893 min_score: Some(80.0),
894 min_severity: None,
895 report_only: false,
896 fail_on_stale_baseline: false,
897 fail_on_parse_error: false,
898 fail_on_issues: false,
899 },
900 since: Some("30d"),
901 min_commits: Some(2),
902 explain: true,
903 summary: false,
904 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
905 trend: true,
906 coverage_inputs: HealthCoverageInputs::default(),
907 performance: true,
908 runtime_coverage: Some(runtime_coverage),
909 churn_file: Some(Path::new("churn.json")),
910 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
911 group_by: Some(GroupByMode::Directory),
912 group_filter: None,
913 trend_from: None,
914 };
915
916 assert_eq!(options.root, root);
917 assert!(
918 options
919 .diff_index
920 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
921 );
922 assert_eq!(options.workspace, Some(workspace.as_slice()));
923 assert!(options.runtime_coverage.is_some());
924 assert_eq!(options.group_by, Some(GroupByMode::Directory));
925 assert_eq!(
926 options.save_snapshot.as_deref(),
927 Some(Path::new(".fallow/snapshots/health.json"))
928 );
929 }
930
931 #[test]
932 fn health_run_options_default_sections_match_health_defaults() {
933 let run = derive_health_run_options(health_run_input());
934
935 assert!(run.sections.complexity);
936 assert!(run.sections.file_scores);
937 assert!(run.sections.hotspots);
938 assert!(run.sections.targets);
939 assert!(run.sections.score);
940 assert!(!run.ownership);
941 }
942
943 #[test]
944 fn health_run_options_effort_requests_targets() {
945 let mut input = health_run_input();
946 input.effort = Some(EffortEstimate::Low);
947
948 let run = derive_health_run_options(input);
949
950 assert!(run.sections.targets);
951 assert_eq!(run.effort, Some(EffortEstimate::Low));
952 }
953
954 struct HealthExecutionOptionsFixture {
955 config_path: Option<PathBuf>,
956 }
957
958 impl HealthExecutionOptionsFixture {
959 const fn new() -> Self {
960 Self { config_path: None }
961 }
962
963 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
964 HealthExecutionOptions {
965 root,
966 config_path: &self.config_path,
967 output: OutputFormat::Human,
968 no_cache: true,
969 threads: 1,
970 quiet: true,
971 complexity_breakdown: false,
972 thresholds: HealthThresholdOverrides::default(),
973 top: None,
974 sort: HealthSort::Cyclomatic,
975 production: false,
976 production_override: None,
977 allow_remote_extends: false,
978 changed_since: None,
979 diff_index: None,
980 use_shared_diff_index: false,
981 workspace: None,
982 changed_workspaces: None,
983 scope: None,
984 baseline: None,
985 save_baseline: None,
986 baseline_mode: crate::baseline::HealthBaselineMode::Count,
987 baseline_mode_explicit: false,
988 complexity: true,
989 file_scores: false,
990 coverage_gaps: false,
991 config_activates_coverage_gaps: false,
992 hotspots: false,
993 ownership: false,
994 ownership_emails: None,
995 targets: false,
996 css: false,
997 css_deep: false,
998 force_full: false,
999 score_only_output: false,
1000 enforce_coverage_gap_gate: true,
1001 effort: None,
1002 score: false,
1003 gates: HealthGateOptions::default(),
1004 since: None,
1005 min_commits: None,
1006 explain: false,
1007 summary: false,
1008 save_snapshot: None,
1009 trend: false,
1010 coverage_inputs: HealthCoverageInputs::default(),
1011 performance: false,
1012 runtime_coverage: None,
1013 churn_file: None,
1014 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
1015 group_by: None,
1016 group_filter: None,
1017 trend_from: None,
1018 }
1019 }
1020 }
1021
1022 #[test]
1029 fn a_churn_file_that_fails_the_re_read_records_the_skip() {
1030 let project = tempfile::tempdir().expect("temp dir");
1031 let root = project.path();
1032 let churn_file = root.join("churn.json");
1033 std::fs::write(&churn_file, "{ not json").expect("churn file");
1034 let fixture = HealthExecutionOptionsFixture::new();
1035 let mut options = fixture.options(root);
1036 options.churn_file = Some(&churn_file);
1037
1038 assert!(
1039 hotspots::fetch_churn_data(&options, &root.join(".fallow")).is_none(),
1040 "an unreadable churn file yields no churn"
1041 );
1042
1043 let recorded = fallow_config::health_stage_workspace_diagnostics(root);
1044 let skip = recorded
1045 .iter()
1046 .find(|entry| entry.kind.id() == "hotspots-skipped")
1047 .expect("the skip is recorded");
1048 assert_eq!(
1049 skip.kind,
1050 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
1051 cause: "churn-file-unreadable".to_owned(),
1052 }
1053 );
1054 assert!(
1055 skip.message.contains("churn.json"),
1056 "the remedy names the file: {}",
1057 skip.message
1058 );
1059 }
1060
1061 #[test]
1062 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
1063 let project = tempfile::tempdir().expect("temp dir");
1064 let fixture = HealthExecutionOptionsFixture::new();
1065 let options = fixture.options(project.path());
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 standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
1073 let project = tempfile::tempdir().expect("temp dir");
1074 let fixture = HealthExecutionOptionsFixture::new();
1075 let mut options = fixture.options(project.path());
1076 options.thresholds.max_crap = Some(0.0);
1077 let config = crate::project_config::default_project_config(project.path()).config;
1078
1079 assert!(!should_precompute_dead_code_analysis(&options, &config));
1080 }
1081
1082 #[test]
1083 fn standalone_health_precomputes_dead_code_for_target_sections() {
1084 let project = tempfile::tempdir().expect("temp dir");
1085 let fixture = HealthExecutionOptionsFixture::new();
1086 let mut options = fixture.options(project.path());
1087 options.thresholds.max_crap = Some(0.0);
1088 options.targets = true;
1089 let config = crate::project_config::default_project_config(project.path()).config;
1090
1091 assert!(should_precompute_dead_code_analysis(&options, &config));
1092 }
1093
1094 #[test]
1095 fn health_run_options_ownership_requires_hotspots() {
1096 let mut input = health_run_input();
1097 input.complexity = true;
1098 input.ownership = true;
1099
1100 let run = derive_health_run_options(input);
1101
1102 assert!(!run.sections.hotspots);
1103 assert!(!run.ownership);
1104
1105 let mut input = health_run_input();
1106 input.ownership = true;
1107 input.hotspots = true;
1108
1109 let run = derive_health_run_options(input);
1110
1111 assert!(run.sections.hotspots);
1112 assert!(run.ownership);
1113 }
1114
1115 #[test]
1116 fn health_run_options_score_gate_forces_score() {
1117 let mut input = health_run_input();
1118 input.gates.min_score = Some(90.0);
1119
1120 let run = derive_health_run_options(input);
1121
1122 assert!(run.sections.score);
1123 assert_eq!(run.gates.min_score, Some(90.0));
1124 }
1125
1126 #[test]
1127 fn coverage_root_accepts_posix_absolute() {
1128 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1129 assert!(
1130 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1131 );
1132 }
1133
1134 #[test]
1135 fn coverage_root_rejects_relative() {
1136 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1137 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1138 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1139 }
1140
1141 #[test]
1142 fn coverage_root_accepts_none() {
1143 assert!(validate_coverage_root_absolute(None).is_ok());
1144 }
1145
1146 #[test]
1147 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1148 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1149 }
1150}