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;
42pub use grouping::validate_group_filter;
43mod health_error;
44mod hotspots;
45mod ignore;
46mod inline;
47pub use inline::{InlineComplexity, inline_complexity};
48mod large_functions;
49mod output_build;
50pub mod ownership;
51mod package_json;
52mod pipeline;
53mod react_hooks;
54mod result;
55mod runner;
56mod runtime_filter;
57mod runtime_sections;
58mod scope;
59pub mod scoring;
62pub mod styling_score;
63mod tailwind_theme;
64mod targets;
65mod threshold_overrides;
66mod timings;
67mod vital_data;
68mod vital_signs_scope;
69
70pub use crate::results::HealthAnalysisResult;
71pub use churn_file::validate_health_churn_file;
72pub use css_analytics::StylingAnalysisArtifacts;
73use derived_sections::{
74 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
75};
76use execute::HealthOptions;
77pub use execute::execute_health_inner;
78use file_scores::{
79 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
80 print_slow_churn_note,
81};
82use finding_sort::sort_findings;
83pub use health_error::HealthError;
84pub use hotspots::{
85 TargetChurnEvidence, TargetChurnOptions, TargetChurnOutcome, analyze_target_churn,
86};
87pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
88pub use runner::{
89 run_ungrouped_health, run_ungrouped_health_with_session,
90 run_ungrouped_health_with_session_artifacts,
91};
92use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
93use vital_signs_scope::{
94 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
95 compute_vital_signs_and_counts,
96};
97
98pub(crate) fn build_styling_analysis_artifacts(
99 files: &[crate::discover::DiscoveredFile],
100 modules: &[crate::source::ModuleInfo],
101 config: &fallow_config::ResolvedConfig,
102) -> StylingAnalysisArtifacts {
103 css_analytics::build_styling_analysis_artifacts(files, modules, config)
104}
105
106#[must_use]
108pub fn shared_parse_data_from_artifacts(
109 results: &AnalysisResults,
110 graph: Option<RetainedModuleGraph>,
111 modules: Option<Vec<crate::source::ModuleInfo>>,
112 files: Option<Vec<crate::discover::DiscoveredFile>>,
113 workspaces: Vec<WorkspaceInfo>,
114 script_used_packages: impl IntoIterator<Item = String>,
115) -> Option<HealthSharedParseData> {
116 let (Some(modules), Some(files)) = (modules, files) else {
117 return None;
118 };
119 let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
120 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
121 results: results.clone(),
122 timings: None,
123 graph: Some(graph),
124 modules: None,
125 files: None,
126 script_used_packages: script_used_packages.clone(),
127 trace_provenance: crate::trace::TraceProvenance::default(),
128 file_hashes: FxHashMap::default(),
129 });
130 Some(HealthSharedParseData {
131 files,
132 modules,
133 dead_code_results: Some(results.clone()),
134 workspaces,
135 analysis_output,
136 })
137}
138
139#[must_use]
145pub fn should_precompute_dead_code_analysis(
146 options: &HealthExecutionOptions<'_>,
147 config: &fallow_config::ResolvedConfig,
148) -> bool {
149 let max_crap = options
150 .thresholds
151 .max_crap
152 .unwrap_or(config.health.max_crap);
153 options.file_scores
154 || options.coverage_gaps
155 || options.config_activates_coverage_gaps
156 || options.hotspots
157 || options.targets
158 || options.force_full
159 || max_crap > 0.0
160 || options.runtime_coverage.is_some()
161}
162
163pub trait HealthGroupResolver {
169 fn mode_label(&self) -> &'static str;
171 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
173 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
175}
176
177#[derive(Debug, Clone, Copy)]
180pub enum NoGroupResolver {}
181
182#[expect(
183 clippy::uninhabited_references,
184 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
185)]
186impl HealthGroupResolver for NoGroupResolver {
187 fn mode_label(&self) -> &'static str {
188 match *self {}
189 }
190 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
191 match *self {}
192 }
193 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
194 match *self {}
195 }
196}
197
198pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
210 + 'a;
211
212pub struct RuntimeCoverageSeamInput<'a> {
214 pub root: &'a Path,
216 pub modules: &'a [fallow_types::extract::ModuleInfo],
218 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
221 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
223 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
225 pub ignore_set: &'a globset::GlobSet,
227 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
229 pub ws_roots: Option<&'a [PathBuf]>,
231 pub top: Option<usize>,
233 pub codeowners_path: Option<&'a str>,
235 pub quiet: bool,
237 pub output: OutputFormat,
239}
240
241pub struct HealthSeams<'a> {
245 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
247 pub note_graph_structure: &'a dyn Fn(usize, usize),
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum HealthSort {
256 Severity,
259 Cyclomatic,
261 Cognitive,
263 Lines,
265}
266
267#[derive(Debug, Clone, Copy, Default, PartialEq)]
269pub struct HealthThresholdOverrides {
270 pub max_cyclomatic: Option<u16>,
272 pub max_cognitive: Option<u16>,
274 pub max_crap: Option<f64>,
277}
278
279#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
281pub struct HealthCoverageInputs<'a> {
282 pub coverage: Option<&'a Path>,
284 pub coverage_root: Option<&'a Path>,
287 pub coverage_relocated: bool,
293}
294
295pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
302 if let Some(path) = coverage_root
303 && !is_absolute_path_any_platform(path)
304 {
305 return Err(format!(
306 "--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'.",
307 path.display()
308 ));
309 }
310 Ok(())
311}
312
313#[derive(Debug, Clone, Copy, Default, PartialEq)]
315pub struct HealthGateOptions {
316 pub min_score: Option<f64>,
318 pub min_severity: Option<FindingSeverity>,
320 pub report_only: bool,
322 pub fail_on_stale_baseline: bool,
324 pub fail_on_parse_error: bool,
328 pub fail_on_issues: bool,
332}
333
334#[derive(Debug, Clone)]
336pub struct HealthSectionOptions {
337 output: OutputFormat,
338 complexity: bool,
339 file_scores: bool,
340 coverage_gaps: bool,
341 hotspots: bool,
342 targets: bool,
343 css: bool,
344 score: bool,
345 score_gate: bool,
346 snapshot_requested: bool,
347 trend: bool,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub struct DerivedHealthSections {
353 pub any_section: bool,
356 pub complexity: bool,
358 pub file_scores: bool,
360 pub coverage_gaps: bool,
362 pub hotspots: bool,
364 pub targets: bool,
366 pub css: bool,
368 pub score: bool,
370 pub force_full: bool,
373 pub score_only_output: bool,
376}
377
378#[derive(Debug, Clone)]
381pub struct HealthRunOptionsInput<'a> {
382 pub output: OutputFormat,
384 pub thresholds: HealthThresholdOverrides,
386 pub top: Option<usize>,
388 pub sort: HealthSort,
390 pub complexity: bool,
392 pub file_scores: bool,
394 pub coverage_gaps: bool,
396 pub hotspots: bool,
398 pub ownership: bool,
400 pub ownership_emails: Option<EmailMode>,
402 pub targets: bool,
404 pub css: bool,
406 pub effort: Option<EffortEstimate>,
408 pub score: bool,
410 pub gates: HealthGateOptions,
412 pub snapshot_requested: bool,
414 pub trend: bool,
416 pub since: Option<&'a str>,
418 pub min_commits: Option<u32>,
420 pub coverage_inputs: HealthCoverageInputs<'a>,
422 pub runtime_coverage: Option<RuntimeCoverageOptions>,
424}
425
426#[derive(Debug, Clone)]
428pub struct HealthRunOptions<'a> {
429 pub thresholds: HealthThresholdOverrides,
431 pub top: Option<usize>,
433 pub sort: HealthSort,
435 pub sections: DerivedHealthSections,
437 pub ownership: bool,
439 pub ownership_emails: Option<EmailMode>,
441 pub effort: Option<EffortEstimate>,
443 pub gates: HealthGateOptions,
445 pub since: Option<&'a str>,
447 pub min_commits: Option<u32>,
449 pub coverage_inputs: HealthCoverageInputs<'a>,
451 pub runtime_coverage: Option<RuntimeCoverageOptions>,
453}
454
455#[derive(Debug, Clone)]
459pub struct HealthExecutionOptions<'a> {
460 pub root: &'a Path,
462 pub config_path: &'a Option<PathBuf>,
464 pub output: OutputFormat,
466 pub no_cache: bool,
468 pub threads: usize,
470 pub quiet: bool,
472 pub complexity_breakdown: bool,
477 pub thresholds: HealthThresholdOverrides,
479 pub top: Option<usize>,
481 pub sort: HealthSort,
483 pub production: bool,
486 pub production_override: Option<bool>,
489 pub allow_remote_extends: bool,
491 pub changed_since: Option<&'a str>,
493 pub diff_index: Option<&'a DiffIndex>,
495 pub use_shared_diff_index: bool,
498 pub workspace: Option<&'a [String]>,
500 pub changed_workspaces: Option<&'a str>,
502 pub scope: Option<PathBuf>,
507 pub baseline: Option<&'a Path>,
509 pub save_baseline: Option<&'a Path>,
511 pub baseline_mode: crate::baseline::HealthBaselineMode,
516 pub baseline_mode_explicit: bool,
522 pub complexity: bool,
524 pub file_scores: bool,
526 pub coverage_gaps: bool,
528 pub config_activates_coverage_gaps: bool,
531 pub hotspots: bool,
533 pub ownership: bool,
535 pub ownership_emails: Option<EmailMode>,
537 pub targets: bool,
539 pub css: bool,
541 pub css_deep: bool,
543 pub force_full: bool,
546 pub score_only_output: bool,
549 pub enforce_coverage_gap_gate: bool,
551 pub effort: Option<EffortEstimate>,
553 pub score: bool,
555 pub gates: HealthGateOptions,
557 pub since: Option<&'a str>,
559 pub min_commits: Option<u32>,
561 pub explain: bool,
563 pub summary: bool,
565 pub save_snapshot: Option<PathBuf>,
567 pub trend: bool,
569 pub coverage_inputs: HealthCoverageInputs<'a>,
571 pub performance: bool,
573 pub runtime_coverage: Option<RuntimeCoverageOptions>,
575 pub churn_file: Option<&'a Path>,
577 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
579 pub group_by: Option<GroupByMode>,
581 pub group_filter: Option<&'a [String]>,
584 pub trend_from: Option<&'a Path>,
587}
588
589#[must_use]
591fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
592 let score = options.score
593 || options.score_gate
594 || options.trend
595 || matches!(options.output, OutputFormat::Badge);
596 let any_section = options.complexity
597 || options.file_scores
598 || options.coverage_gaps
599 || options.hotspots
600 || options.targets
601 || score;
602 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
603 let force_full = options.snapshot_requested || effective_score;
604
605 DerivedHealthSections {
606 any_section,
607 complexity: if any_section {
608 options.complexity
609 } else {
610 true
611 },
612 file_scores: if any_section {
613 options.file_scores
614 } else {
615 true
616 } || force_full,
617 coverage_gaps: if any_section {
618 options.coverage_gaps
619 } else {
620 false
621 },
622 hotspots: if any_section { options.hotspots } else { true }
623 || options.snapshot_requested
624 || options.trend,
625 targets: if any_section { options.targets } else { true },
626 css: options.css,
627 score: effective_score,
628 force_full,
629 score_only_output: is_health_score_only_output(options, score),
630 }
631}
632
633#[must_use]
635pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
636 let targets = input.targets || input.effort.is_some();
637 let sections = derive_health_sections(&HealthSectionOptions {
638 output: input.output,
639 complexity: input.complexity,
640 file_scores: input.file_scores,
641 coverage_gaps: input.coverage_gaps,
642 hotspots: input.hotspots,
643 targets,
644 css: input.css,
645 score: input.score,
646 score_gate: input.gates.min_score.is_some(),
647 snapshot_requested: input.snapshot_requested,
648 trend: input.trend,
649 });
650
651 HealthRunOptions {
652 thresholds: input.thresholds,
653 top: input.top,
654 sort: input.sort,
655 sections,
656 ownership: input.ownership && sections.hotspots,
657 ownership_emails: input.ownership_emails,
658 effort: input.effort,
659 gates: input.gates,
660 since: input.since,
661 min_commits: input.min_commits,
662 coverage_inputs: input.coverage_inputs,
663 runtime_coverage: input.runtime_coverage,
664 }
665}
666
667fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
668 score
669 && !options.complexity
670 && !options.file_scores
671 && !options.coverage_gaps
672 && !options.hotspots
673 && !options.targets
674 && !options.trend
675}
676
677#[derive(Debug, Clone)]
679pub struct ComplexitySectionOptions {
680 complexity: bool,
681 file_scores: bool,
682 coverage_gaps: bool,
683 hotspots: bool,
684 ownership: bool,
685 targets: bool,
686 css: bool,
687 score: bool,
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
692pub struct DerivedComplexityOptions {
693 any_section: bool,
694 complexity: bool,
695 file_scores: bool,
696 coverage_gaps: bool,
697 hotspots: bool,
698 ownership: bool,
699 targets: bool,
700 force_full: bool,
701 score_only_output: bool,
702 score: bool,
703}
704
705#[must_use]
707pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
708 let requested_hotspots = options.hotspots || options.ownership;
709 let sections = derive_health_sections(&HealthSectionOptions {
710 output: OutputFormat::Human,
711 complexity: options.complexity,
712 file_scores: options.file_scores,
713 coverage_gaps: options.coverage_gaps,
714 hotspots: requested_hotspots,
715 targets: options.targets,
716 css: options.css,
717 score: options.score,
718 score_gate: false,
719 snapshot_requested: false,
720 trend: false,
721 });
722
723 DerivedComplexityOptions {
724 any_section: sections.any_section,
725 complexity: sections.complexity,
726 file_scores: sections.file_scores,
727 coverage_gaps: sections.coverage_gaps,
728 hotspots: sections.hotspots,
729 ownership: options.ownership && sections.hotspots,
730 targets: sections.targets,
731 force_full: sections.force_full,
732 score_only_output: sections.score_only_output,
733 score: sections.score,
734 }
735}
736
737#[derive(Debug, Clone, PartialEq)]
740pub struct ComplexityRunOptions<'a> {
741 thresholds: HealthThresholdOverrides,
742 top: Option<usize>,
743 sort: HealthSort,
744 complexity_breakdown: bool,
745 sections: DerivedComplexityOptions,
746 ownership_emails: Option<EmailMode>,
747 effort: Option<EffortEstimate>,
748 css: bool,
749 since: Option<&'a str>,
750 min_commits: Option<u32>,
751 coverage_inputs: HealthCoverageInputs<'a>,
752}
753
754#[derive(Debug, Clone)]
756pub struct RuntimeCoverageOptions {
757 pub path: PathBuf,
759 pub min_invocations_hot: u64,
761 pub min_observation_volume: Option<u32>,
766 pub low_traffic_threshold: Option<f64>,
770 pub license_jwt: String,
772 pub watermark: Option<RuntimeCoverageWatermark>,
774}
775
776pub struct HealthSharedParseData {
778 pub files: Vec<fallow_types::discover::DiscoveredFile>,
780 pub modules: Vec<fallow_types::extract::ModuleInfo>,
782 pub dead_code_results: Option<AnalysisResults>,
784 pub workspaces: Vec<WorkspaceInfo>,
786 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 fn health_run_input() -> HealthRunOptionsInput<'static> {
795 HealthRunOptionsInput {
796 output: OutputFormat::Json,
797 thresholds: HealthThresholdOverrides::default(),
798 top: None,
799 sort: HealthSort::Cyclomatic,
800 complexity: false,
801 file_scores: false,
802 coverage_gaps: false,
803 hotspots: false,
804 ownership: false,
805 ownership_emails: None,
806 targets: false,
807 css: false,
808 effort: None,
809 score: false,
810 gates: HealthGateOptions::default(),
811 snapshot_requested: false,
812 trend: false,
813 since: None,
814 min_commits: None,
815 coverage_inputs: HealthCoverageInputs::default(),
816 runtime_coverage: None,
817 }
818 }
819
820 #[test]
821 fn health_execution_options_own_shared_runner_scope() {
822 let root = Path::new("/project");
823 let config_path = None;
824 let workspace = vec!["packages/app".to_string()];
825 let diff = DiffIndex::from_unified_diff(
826 "diff --git a/src/a.ts b/src/a.ts\n\
827 --- a/src/a.ts\n\
828 +++ b/src/a.ts\n\
829 @@ -0,0 +1,1 @@\n\
830 +new line\n",
831 );
832 let runtime_coverage = RuntimeCoverageOptions {
833 path: PathBuf::from("coverage/v8"),
834 min_invocations_hot: 10,
835 min_observation_volume: Some(500),
836 low_traffic_threshold: Some(0.01),
837 license_jwt: "test.jwt".to_string(),
838 watermark: None,
839 };
840
841 let options = HealthExecutionOptions {
842 root,
843 config_path: &config_path,
844 output: OutputFormat::Json,
845 no_cache: true,
846 threads: 2,
847 quiet: true,
848 complexity_breakdown: true,
849 thresholds: HealthThresholdOverrides::default(),
850 top: Some(5),
851 sort: HealthSort::Cognitive,
852 production: true,
853 production_override: Some(true),
854 allow_remote_extends: false,
855 changed_since: Some("HEAD~1"),
856 diff_index: Some(&diff),
857 use_shared_diff_index: false,
858 workspace: Some(&workspace),
859 changed_workspaces: None,
860 scope: None,
861 baseline: Some(Path::new(".fallow/health-baseline.json")),
862 save_baseline: None,
863 baseline_mode: crate::baseline::HealthBaselineMode::Count,
864 baseline_mode_explicit: false,
865 complexity: true,
866 file_scores: true,
867 coverage_gaps: false,
868 config_activates_coverage_gaps: false,
869 hotspots: true,
870 ownership: false,
871 ownership_emails: None,
872 targets: true,
873 css: false,
874 css_deep: false,
875 force_full: true,
876 score_only_output: false,
877 enforce_coverage_gap_gate: true,
878 effort: Some(EffortEstimate::Low),
879 score: true,
880 gates: HealthGateOptions {
881 min_score: Some(80.0),
882 min_severity: None,
883 report_only: false,
884 fail_on_stale_baseline: false,
885 fail_on_parse_error: false,
886 fail_on_issues: false,
887 },
888 since: Some("30d"),
889 min_commits: Some(2),
890 explain: true,
891 summary: false,
892 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
893 trend: true,
894 coverage_inputs: HealthCoverageInputs::default(),
895 performance: true,
896 runtime_coverage: Some(runtime_coverage),
897 churn_file: Some(Path::new("churn.json")),
898 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
899 group_by: Some(GroupByMode::Directory),
900 group_filter: None,
901 trend_from: None,
902 };
903
904 assert_eq!(options.root, root);
905 assert!(
906 options
907 .diff_index
908 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
909 );
910 assert_eq!(options.workspace, Some(workspace.as_slice()));
911 assert!(options.runtime_coverage.is_some());
912 assert_eq!(options.group_by, Some(GroupByMode::Directory));
913 assert_eq!(
914 options.save_snapshot.as_deref(),
915 Some(Path::new(".fallow/snapshots/health.json"))
916 );
917 }
918
919 #[test]
920 fn health_run_options_default_sections_match_health_defaults() {
921 let run = derive_health_run_options(health_run_input());
922
923 assert!(run.sections.complexity);
924 assert!(run.sections.file_scores);
925 assert!(run.sections.hotspots);
926 assert!(run.sections.targets);
927 assert!(run.sections.score);
928 assert!(!run.ownership);
929 }
930
931 #[test]
932 fn health_run_options_effort_requests_targets() {
933 let mut input = health_run_input();
934 input.effort = Some(EffortEstimate::Low);
935
936 let run = derive_health_run_options(input);
937
938 assert!(run.sections.targets);
939 assert_eq!(run.effort, Some(EffortEstimate::Low));
940 }
941
942 struct HealthExecutionOptionsFixture {
943 config_path: Option<PathBuf>,
944 }
945
946 impl HealthExecutionOptionsFixture {
947 const fn new() -> Self {
948 Self { config_path: None }
949 }
950
951 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
952 HealthExecutionOptions {
953 root,
954 config_path: &self.config_path,
955 output: OutputFormat::Human,
956 no_cache: true,
957 threads: 1,
958 quiet: true,
959 complexity_breakdown: false,
960 thresholds: HealthThresholdOverrides::default(),
961 top: None,
962 sort: HealthSort::Cyclomatic,
963 production: false,
964 production_override: None,
965 allow_remote_extends: false,
966 changed_since: None,
967 diff_index: None,
968 use_shared_diff_index: false,
969 workspace: None,
970 changed_workspaces: None,
971 scope: None,
972 baseline: None,
973 save_baseline: None,
974 baseline_mode: crate::baseline::HealthBaselineMode::Count,
975 baseline_mode_explicit: false,
976 complexity: true,
977 file_scores: false,
978 coverage_gaps: false,
979 config_activates_coverage_gaps: false,
980 hotspots: false,
981 ownership: false,
982 ownership_emails: None,
983 targets: false,
984 css: false,
985 css_deep: false,
986 force_full: false,
987 score_only_output: false,
988 enforce_coverage_gap_gate: true,
989 effort: None,
990 score: false,
991 gates: HealthGateOptions::default(),
992 since: None,
993 min_commits: None,
994 explain: false,
995 summary: false,
996 save_snapshot: None,
997 trend: false,
998 coverage_inputs: HealthCoverageInputs::default(),
999 performance: false,
1000 runtime_coverage: None,
1001 churn_file: None,
1002 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
1003 group_by: None,
1004 group_filter: None,
1005 trend_from: None,
1006 }
1007 }
1008 }
1009
1010 #[test]
1017 fn a_churn_file_that_fails_the_re_read_records_the_skip() {
1018 let project = tempfile::tempdir().expect("temp dir");
1019 let root = project.path();
1020 let churn_file = root.join("churn.json");
1021 std::fs::write(&churn_file, "{ not json").expect("churn file");
1022 let fixture = HealthExecutionOptionsFixture::new();
1023 let mut options = fixture.options(root);
1024 options.churn_file = Some(&churn_file);
1025
1026 assert!(
1027 hotspots::fetch_churn_data(&options, &root.join(".fallow")).is_none(),
1028 "an unreadable churn file yields no churn"
1029 );
1030
1031 let recorded = fallow_config::health_stage_workspace_diagnostics(root);
1032 let skip = recorded
1033 .iter()
1034 .find(|entry| entry.kind.id() == "hotspots-skipped")
1035 .expect("the skip is recorded");
1036 assert_eq!(
1037 skip.kind,
1038 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
1039 cause: "churn-file-unreadable".to_owned(),
1040 }
1041 );
1042 assert!(
1043 skip.message.contains("churn.json"),
1044 "the remedy names the file: {}",
1045 skip.message
1046 );
1047 }
1048
1049 #[test]
1050 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
1051 let project = tempfile::tempdir().expect("temp dir");
1052 let fixture = HealthExecutionOptionsFixture::new();
1053 let options = fixture.options(project.path());
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_skips_precompute_when_no_section_needs_analysis_artifacts() {
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 let config = crate::project_config::default_project_config(project.path()).config;
1066
1067 assert!(!should_precompute_dead_code_analysis(&options, &config));
1068 }
1069
1070 #[test]
1071 fn standalone_health_precomputes_dead_code_for_target_sections() {
1072 let project = tempfile::tempdir().expect("temp dir");
1073 let fixture = HealthExecutionOptionsFixture::new();
1074 let mut options = fixture.options(project.path());
1075 options.thresholds.max_crap = Some(0.0);
1076 options.targets = true;
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 health_run_options_ownership_requires_hotspots() {
1084 let mut input = health_run_input();
1085 input.complexity = true;
1086 input.ownership = true;
1087
1088 let run = derive_health_run_options(input);
1089
1090 assert!(!run.sections.hotspots);
1091 assert!(!run.ownership);
1092
1093 let mut input = health_run_input();
1094 input.ownership = true;
1095 input.hotspots = true;
1096
1097 let run = derive_health_run_options(input);
1098
1099 assert!(run.sections.hotspots);
1100 assert!(run.ownership);
1101 }
1102
1103 #[test]
1104 fn health_run_options_score_gate_forces_score() {
1105 let mut input = health_run_input();
1106 input.gates.min_score = Some(90.0);
1107
1108 let run = derive_health_run_options(input);
1109
1110 assert!(run.sections.score);
1111 assert_eq!(run.gates.min_score, Some(90.0));
1112 }
1113
1114 #[test]
1115 fn coverage_root_accepts_posix_absolute() {
1116 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1117 assert!(
1118 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1119 );
1120 }
1121
1122 #[test]
1123 fn coverage_root_rejects_relative() {
1124 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1125 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1126 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1127 }
1128
1129 #[test]
1130 fn coverage_root_accepts_none() {
1131 assert!(validate_coverage_root_absolute(None).is_ok());
1132 }
1133
1134 #[test]
1135 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1136 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1137 }
1138}