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 UngroupedHealthSession, 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 mut results = results.clone();
124 results.restore_cascade_findings();
125 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
126 results: results.clone(),
127 timings: None,
128 graph: Some(graph),
129 modules: None,
130 files: None,
131 script_used_packages: script_used_packages.clone(),
132 trace_provenance: crate::trace::TraceProvenance::default(),
133 file_hashes: FxHashMap::default(),
134 });
135 Some(HealthSharedParseData {
136 files,
137 modules,
138 dead_code_results: Some(results),
139 workspaces,
140 analysis_output,
141 })
142}
143
144#[must_use]
150pub fn should_precompute_dead_code_analysis(
151 options: &HealthExecutionOptions<'_>,
152 config: &fallow_config::ResolvedConfig,
153) -> bool {
154 let max_crap = options
155 .thresholds
156 .max_crap
157 .unwrap_or(config.health.max_crap);
158 options.file_scores
159 || options.coverage_gaps
160 || options.config_activates_coverage_gaps
161 || options.hotspots
162 || options.targets
163 || options.force_full
164 || max_crap > 0.0
165 || options.runtime_coverage.is_some()
166}
167
168pub trait HealthGroupResolver {
174 fn mode_label(&self) -> &'static str;
176 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
178 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
180}
181
182#[derive(Debug, Clone, Copy)]
185pub enum NoGroupResolver {}
186
187#[expect(
188 clippy::uninhabited_references,
189 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
190)]
191impl HealthGroupResolver for NoGroupResolver {
192 fn mode_label(&self) -> &'static str {
193 match *self {}
194 }
195 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
196 match *self {}
197 }
198 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
199 match *self {}
200 }
201}
202
203pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
215 + 'a;
216
217pub struct RuntimeCoverageSeamInput<'a> {
219 pub root: &'a Path,
221 pub modules: &'a [fallow_types::extract::ModuleInfo],
223 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
226 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
228 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
230 pub ignore_set: &'a globset::GlobSet,
232 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
234 pub ws_roots: Option<&'a [PathBuf]>,
236 pub top: Option<usize>,
238 pub codeowners_path: Option<&'a str>,
240 pub quiet: bool,
242 pub output: OutputFormat,
244}
245
246pub struct HealthSeams<'a> {
250 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
252 pub note_graph_structure: &'a dyn Fn(usize, usize),
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum HealthSort {
261 Severity,
264 Cyclomatic,
266 Cognitive,
268 Lines,
270}
271
272#[derive(Debug, Clone, Copy, Default, PartialEq)]
274pub struct HealthThresholdOverrides {
275 pub max_cyclomatic: Option<u16>,
277 pub max_cognitive: Option<u16>,
279 pub max_crap: Option<f64>,
282}
283
284#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
286pub struct HealthCoverageInputs<'a> {
287 pub coverage: Option<&'a Path>,
289 pub coverage_root: Option<&'a Path>,
292 pub coverage_relocated: bool,
298}
299
300pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
307 if let Some(path) = coverage_root
308 && !is_absolute_path_any_platform(path)
309 {
310 return Err(format!(
311 "--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'.",
312 path.display()
313 ));
314 }
315 Ok(())
316}
317
318#[derive(Debug, Clone, Copy, Default, PartialEq)]
320pub struct HealthGateOptions {
321 pub min_score: Option<f64>,
323 pub min_severity: Option<FindingSeverity>,
325 pub report_only: bool,
327 pub fail_on_stale_baseline: bool,
329 pub fail_on_parse_error: bool,
333 pub fail_on_issues: bool,
337}
338
339#[derive(Debug, Clone)]
341pub struct HealthSectionOptions {
342 output: OutputFormat,
343 complexity: bool,
344 file_scores: bool,
345 coverage_gaps: bool,
346 hotspots: bool,
347 targets: bool,
348 css: bool,
349 score: bool,
350 score_gate: bool,
351 snapshot_requested: bool,
352 trend: bool,
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub struct DerivedHealthSections {
358 pub any_section: bool,
361 pub complexity: bool,
363 pub file_scores: bool,
365 pub coverage_gaps: bool,
367 pub hotspots: bool,
369 pub targets: bool,
371 pub css: bool,
373 pub score: bool,
375 pub force_full: bool,
378 pub score_only_output: bool,
381}
382
383#[derive(Debug, Clone)]
386pub struct HealthRunOptionsInput<'a> {
387 pub output: OutputFormat,
389 pub thresholds: HealthThresholdOverrides,
391 pub top: Option<usize>,
393 pub sort: HealthSort,
395 pub complexity: bool,
397 pub file_scores: bool,
399 pub coverage_gaps: bool,
401 pub hotspots: bool,
403 pub ownership: bool,
405 pub ownership_emails: Option<EmailMode>,
407 pub targets: bool,
409 pub css: bool,
411 pub effort: Option<EffortEstimate>,
413 pub score: bool,
415 pub gates: HealthGateOptions,
417 pub snapshot_requested: bool,
419 pub trend: bool,
421 pub since: Option<&'a str>,
423 pub min_commits: Option<u32>,
425 pub coverage_inputs: HealthCoverageInputs<'a>,
427 pub runtime_coverage: Option<RuntimeCoverageOptions>,
429}
430
431#[derive(Debug, Clone)]
433pub struct HealthRunOptions<'a> {
434 pub thresholds: HealthThresholdOverrides,
436 pub top: Option<usize>,
438 pub sort: HealthSort,
440 pub sections: DerivedHealthSections,
442 pub ownership: bool,
444 pub ownership_emails: Option<EmailMode>,
446 pub effort: Option<EffortEstimate>,
448 pub gates: HealthGateOptions,
450 pub since: Option<&'a str>,
452 pub min_commits: Option<u32>,
454 pub coverage_inputs: HealthCoverageInputs<'a>,
456 pub runtime_coverage: Option<RuntimeCoverageOptions>,
458}
459
460#[derive(Debug, Clone)]
464pub struct HealthExecutionOptions<'a> {
465 pub root: &'a Path,
467 pub config_path: &'a Option<PathBuf>,
469 pub output: OutputFormat,
471 pub no_cache: bool,
473 pub threads: usize,
475 pub quiet: bool,
477 pub complexity_breakdown: bool,
482 pub thresholds: HealthThresholdOverrides,
484 pub top: Option<usize>,
486 pub sort: HealthSort,
488 pub production: bool,
491 pub production_override: Option<bool>,
494 pub allow_remote_extends: bool,
496 pub changed_since: Option<&'a str>,
498 pub diff_index: Option<&'a DiffIndex>,
500 pub use_shared_diff_index: bool,
503 pub workspace: Option<&'a [String]>,
505 pub changed_workspaces: Option<&'a str>,
507 pub scope: Option<PathBuf>,
512 pub baseline: Option<&'a Path>,
514 pub save_baseline: Option<&'a Path>,
516 pub baseline_mode: crate::baseline::HealthBaselineMode,
521 pub baseline_mode_explicit: bool,
527 pub complexity: bool,
529 pub file_scores: bool,
531 pub coverage_gaps: bool,
533 pub config_activates_coverage_gaps: bool,
536 pub hotspots: bool,
538 pub ownership: bool,
540 pub ownership_emails: Option<EmailMode>,
542 pub targets: bool,
544 pub css: bool,
546 pub css_deep: bool,
548 pub force_full: bool,
551 pub score_only_output: bool,
554 pub enforce_coverage_gap_gate: bool,
556 pub effort: Option<EffortEstimate>,
558 pub score: bool,
560 pub gates: HealthGateOptions,
562 pub since: Option<&'a str>,
564 pub min_commits: Option<u32>,
566 pub explain: bool,
568 pub summary: bool,
570 pub save_snapshot: Option<PathBuf>,
572 pub trend: bool,
574 pub coverage_inputs: HealthCoverageInputs<'a>,
576 pub performance: bool,
578 pub runtime_coverage: Option<RuntimeCoverageOptions>,
580 pub churn_file: Option<&'a Path>,
582 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
584 pub group_by: Option<GroupByMode>,
586 pub group_filter: Option<&'a [String]>,
589 pub trend_from: Option<&'a Path>,
592}
593
594#[must_use]
596fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
597 let score = options.score
598 || options.score_gate
599 || options.trend
600 || matches!(options.output, OutputFormat::Badge);
601 let any_section = options.complexity
602 || options.file_scores
603 || options.coverage_gaps
604 || options.hotspots
605 || options.targets
606 || score;
607 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
608 let force_full = options.snapshot_requested || effective_score;
609
610 DerivedHealthSections {
611 any_section,
612 complexity: if any_section {
613 options.complexity
614 } else {
615 true
616 },
617 file_scores: if any_section {
618 options.file_scores
619 } else {
620 true
621 } || force_full,
622 coverage_gaps: if any_section {
623 options.coverage_gaps
624 } else {
625 false
626 },
627 hotspots: if any_section { options.hotspots } else { true }
628 || options.snapshot_requested
629 || options.trend,
630 targets: if any_section { options.targets } else { true },
631 css: options.css,
632 score: effective_score,
633 force_full,
634 score_only_output: is_health_score_only_output(options, score),
635 }
636}
637
638#[must_use]
640pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
641 let targets = input.targets || input.effort.is_some();
642 let sections = derive_health_sections(&HealthSectionOptions {
643 output: input.output,
644 complexity: input.complexity,
645 file_scores: input.file_scores,
646 coverage_gaps: input.coverage_gaps,
647 hotspots: input.hotspots,
648 targets,
649 css: input.css,
650 score: input.score,
651 score_gate: input.gates.min_score.is_some(),
652 snapshot_requested: input.snapshot_requested,
653 trend: input.trend,
654 });
655
656 HealthRunOptions {
657 thresholds: input.thresholds,
658 top: input.top,
659 sort: input.sort,
660 sections,
661 ownership: input.ownership && sections.hotspots,
662 ownership_emails: input.ownership_emails,
663 effort: input.effort,
664 gates: input.gates,
665 since: input.since,
666 min_commits: input.min_commits,
667 coverage_inputs: input.coverage_inputs,
668 runtime_coverage: input.runtime_coverage,
669 }
670}
671
672fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
673 score
674 && !options.complexity
675 && !options.file_scores
676 && !options.coverage_gaps
677 && !options.hotspots
678 && !options.targets
679 && !options.trend
680}
681
682#[derive(Debug, Clone)]
684pub struct ComplexitySectionOptions {
685 complexity: bool,
686 file_scores: bool,
687 coverage_gaps: bool,
688 hotspots: bool,
689 ownership: bool,
690 targets: bool,
691 css: bool,
692 score: bool,
693}
694
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub struct DerivedComplexityOptions {
698 any_section: bool,
699 complexity: bool,
700 file_scores: bool,
701 coverage_gaps: bool,
702 hotspots: bool,
703 ownership: bool,
704 targets: bool,
705 force_full: bool,
706 score_only_output: bool,
707 score: bool,
708}
709
710#[must_use]
712pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
713 let requested_hotspots = options.hotspots || options.ownership;
714 let sections = derive_health_sections(&HealthSectionOptions {
715 output: OutputFormat::Human,
716 complexity: options.complexity,
717 file_scores: options.file_scores,
718 coverage_gaps: options.coverage_gaps,
719 hotspots: requested_hotspots,
720 targets: options.targets,
721 css: options.css,
722 score: options.score,
723 score_gate: false,
724 snapshot_requested: false,
725 trend: false,
726 });
727
728 DerivedComplexityOptions {
729 any_section: sections.any_section,
730 complexity: sections.complexity,
731 file_scores: sections.file_scores,
732 coverage_gaps: sections.coverage_gaps,
733 hotspots: sections.hotspots,
734 ownership: options.ownership && sections.hotspots,
735 targets: sections.targets,
736 force_full: sections.force_full,
737 score_only_output: sections.score_only_output,
738 score: sections.score,
739 }
740}
741
742#[derive(Debug, Clone, PartialEq)]
745pub struct ComplexityRunOptions<'a> {
746 thresholds: HealthThresholdOverrides,
747 top: Option<usize>,
748 sort: HealthSort,
749 complexity_breakdown: bool,
750 sections: DerivedComplexityOptions,
751 ownership_emails: Option<EmailMode>,
752 effort: Option<EffortEstimate>,
753 css: bool,
754 since: Option<&'a str>,
755 min_commits: Option<u32>,
756 coverage_inputs: HealthCoverageInputs<'a>,
757}
758
759#[derive(Debug, Clone)]
761pub struct RuntimeCoverageOptions {
762 pub path: PathBuf,
764 pub min_invocations_hot: u64,
766 pub min_observation_volume: Option<u32>,
771 pub low_traffic_threshold: Option<f64>,
775 pub license_jwt: String,
777 pub watermark: Option<RuntimeCoverageWatermark>,
779}
780
781pub struct HealthSharedParseData {
783 pub files: Vec<fallow_types::discover::DiscoveredFile>,
785 pub modules: Vec<fallow_types::extract::ModuleInfo>,
787 pub dead_code_results: Option<AnalysisResults>,
789 pub workspaces: Vec<WorkspaceInfo>,
791 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798
799 fn health_run_input() -> HealthRunOptionsInput<'static> {
800 HealthRunOptionsInput {
801 output: OutputFormat::Json,
802 thresholds: HealthThresholdOverrides::default(),
803 top: None,
804 sort: HealthSort::Cyclomatic,
805 complexity: false,
806 file_scores: false,
807 coverage_gaps: false,
808 hotspots: false,
809 ownership: false,
810 ownership_emails: None,
811 targets: false,
812 css: false,
813 effort: None,
814 score: false,
815 gates: HealthGateOptions::default(),
816 snapshot_requested: false,
817 trend: false,
818 since: None,
819 min_commits: None,
820 coverage_inputs: HealthCoverageInputs::default(),
821 runtime_coverage: None,
822 }
823 }
824
825 #[test]
826 fn health_execution_options_own_shared_runner_scope() {
827 let root = Path::new("/project");
828 let config_path = None;
829 let workspace = vec!["packages/app".to_string()];
830 let diff = DiffIndex::from_unified_diff(
831 "diff --git a/src/a.ts b/src/a.ts\n\
832 --- a/src/a.ts\n\
833 +++ b/src/a.ts\n\
834 @@ -0,0 +1,1 @@\n\
835 +new line\n",
836 );
837 let runtime_coverage = RuntimeCoverageOptions {
838 path: PathBuf::from("coverage/v8"),
839 min_invocations_hot: 10,
840 min_observation_volume: Some(500),
841 low_traffic_threshold: Some(0.01),
842 license_jwt: "test.jwt".to_string(),
843 watermark: None,
844 };
845
846 let options = HealthExecutionOptions {
847 root,
848 config_path: &config_path,
849 output: OutputFormat::Json,
850 no_cache: true,
851 threads: 2,
852 quiet: true,
853 complexity_breakdown: true,
854 thresholds: HealthThresholdOverrides::default(),
855 top: Some(5),
856 sort: HealthSort::Cognitive,
857 production: true,
858 production_override: Some(true),
859 allow_remote_extends: false,
860 changed_since: Some("HEAD~1"),
861 diff_index: Some(&diff),
862 use_shared_diff_index: false,
863 workspace: Some(&workspace),
864 changed_workspaces: None,
865 scope: None,
866 baseline: Some(Path::new(".fallow/health-baseline.json")),
867 save_baseline: None,
868 baseline_mode: crate::baseline::HealthBaselineMode::Count,
869 baseline_mode_explicit: false,
870 complexity: true,
871 file_scores: true,
872 coverage_gaps: false,
873 config_activates_coverage_gaps: false,
874 hotspots: true,
875 ownership: false,
876 ownership_emails: None,
877 targets: true,
878 css: false,
879 css_deep: false,
880 force_full: true,
881 score_only_output: false,
882 enforce_coverage_gap_gate: true,
883 effort: Some(EffortEstimate::Low),
884 score: true,
885 gates: HealthGateOptions {
886 min_score: Some(80.0),
887 min_severity: None,
888 report_only: false,
889 fail_on_stale_baseline: false,
890 fail_on_parse_error: false,
891 fail_on_issues: false,
892 },
893 since: Some("30d"),
894 min_commits: Some(2),
895 explain: true,
896 summary: false,
897 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
898 trend: true,
899 coverage_inputs: HealthCoverageInputs::default(),
900 performance: true,
901 runtime_coverage: Some(runtime_coverage),
902 churn_file: Some(Path::new("churn.json")),
903 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
904 group_by: Some(GroupByMode::Directory),
905 group_filter: None,
906 trend_from: None,
907 };
908
909 assert_eq!(options.root, root);
910 assert!(
911 options
912 .diff_index
913 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
914 );
915 assert_eq!(options.workspace, Some(workspace.as_slice()));
916 assert!(options.runtime_coverage.is_some());
917 assert_eq!(options.group_by, Some(GroupByMode::Directory));
918 assert_eq!(
919 options.save_snapshot.as_deref(),
920 Some(Path::new(".fallow/snapshots/health.json"))
921 );
922 }
923
924 #[test]
925 fn health_run_options_default_sections_match_health_defaults() {
926 let run = derive_health_run_options(health_run_input());
927
928 assert!(run.sections.complexity);
929 assert!(run.sections.file_scores);
930 assert!(run.sections.hotspots);
931 assert!(run.sections.targets);
932 assert!(run.sections.score);
933 assert!(!run.ownership);
934 }
935
936 #[test]
937 fn health_run_options_effort_requests_targets() {
938 let mut input = health_run_input();
939 input.effort = Some(EffortEstimate::Low);
940
941 let run = derive_health_run_options(input);
942
943 assert!(run.sections.targets);
944 assert_eq!(run.effort, Some(EffortEstimate::Low));
945 }
946
947 struct HealthExecutionOptionsFixture {
948 config_path: Option<PathBuf>,
949 }
950
951 impl HealthExecutionOptionsFixture {
952 const fn new() -> Self {
953 Self { config_path: None }
954 }
955
956 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
957 HealthExecutionOptions {
958 root,
959 config_path: &self.config_path,
960 output: OutputFormat::Human,
961 no_cache: true,
962 threads: 1,
963 quiet: true,
964 complexity_breakdown: false,
965 thresholds: HealthThresholdOverrides::default(),
966 top: None,
967 sort: HealthSort::Cyclomatic,
968 production: false,
969 production_override: None,
970 allow_remote_extends: false,
971 changed_since: None,
972 diff_index: None,
973 use_shared_diff_index: false,
974 workspace: None,
975 changed_workspaces: None,
976 scope: None,
977 baseline: None,
978 save_baseline: None,
979 baseline_mode: crate::baseline::HealthBaselineMode::Count,
980 baseline_mode_explicit: false,
981 complexity: true,
982 file_scores: false,
983 coverage_gaps: false,
984 config_activates_coverage_gaps: false,
985 hotspots: false,
986 ownership: false,
987 ownership_emails: None,
988 targets: false,
989 css: false,
990 css_deep: false,
991 force_full: false,
992 score_only_output: false,
993 enforce_coverage_gap_gate: true,
994 effort: None,
995 score: false,
996 gates: HealthGateOptions::default(),
997 since: None,
998 min_commits: None,
999 explain: false,
1000 summary: false,
1001 save_snapshot: None,
1002 trend: false,
1003 coverage_inputs: HealthCoverageInputs::default(),
1004 performance: false,
1005 runtime_coverage: None,
1006 churn_file: None,
1007 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
1008 group_by: None,
1009 group_filter: None,
1010 trend_from: None,
1011 }
1012 }
1013 }
1014
1015 #[test]
1022 fn a_churn_file_that_fails_the_re_read_records_the_skip() {
1023 let project = tempfile::tempdir().expect("temp dir");
1024 let root = project.path();
1025 let churn_file = root.join("churn.json");
1026 std::fs::write(&churn_file, "{ not json").expect("churn file");
1027 let fixture = HealthExecutionOptionsFixture::new();
1028 let mut options = fixture.options(root);
1029 options.churn_file = Some(&churn_file);
1030
1031 assert!(
1032 hotspots::fetch_churn_data(&options, &root.join(".fallow")).is_none(),
1033 "an unreadable churn file yields no churn"
1034 );
1035
1036 let recorded = fallow_config::health_stage_workspace_diagnostics(root);
1037 let skip = recorded
1038 .iter()
1039 .find(|entry| entry.kind.id() == "hotspots-skipped")
1040 .expect("the skip is recorded");
1041 assert_eq!(
1042 skip.kind,
1043 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
1044 cause: "churn-file-unreadable".to_owned(),
1045 }
1046 );
1047 assert!(
1048 skip.message.contains("churn.json"),
1049 "the remedy names the file: {}",
1050 skip.message
1051 );
1052 }
1053
1054 #[test]
1055 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
1056 let project = tempfile::tempdir().expect("temp dir");
1057 let fixture = HealthExecutionOptionsFixture::new();
1058 let options = fixture.options(project.path());
1059 let config = crate::project_config::default_project_config(project.path()).config;
1060
1061 assert!(should_precompute_dead_code_analysis(&options, &config));
1062 }
1063
1064 #[test]
1065 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
1066 let project = tempfile::tempdir().expect("temp dir");
1067 let fixture = HealthExecutionOptionsFixture::new();
1068 let mut options = fixture.options(project.path());
1069 options.thresholds.max_crap = Some(0.0);
1070 let config = crate::project_config::default_project_config(project.path()).config;
1071
1072 assert!(!should_precompute_dead_code_analysis(&options, &config));
1073 }
1074
1075 #[test]
1076 fn standalone_health_precomputes_dead_code_for_target_sections() {
1077 let project = tempfile::tempdir().expect("temp dir");
1078 let fixture = HealthExecutionOptionsFixture::new();
1079 let mut options = fixture.options(project.path());
1080 options.thresholds.max_crap = Some(0.0);
1081 options.targets = true;
1082 let config = crate::project_config::default_project_config(project.path()).config;
1083
1084 assert!(should_precompute_dead_code_analysis(&options, &config));
1085 }
1086
1087 #[test]
1088 fn health_run_options_ownership_requires_hotspots() {
1089 let mut input = health_run_input();
1090 input.complexity = true;
1091 input.ownership = true;
1092
1093 let run = derive_health_run_options(input);
1094
1095 assert!(!run.sections.hotspots);
1096 assert!(!run.ownership);
1097
1098 let mut input = health_run_input();
1099 input.ownership = true;
1100 input.hotspots = true;
1101
1102 let run = derive_health_run_options(input);
1103
1104 assert!(run.sections.hotspots);
1105 assert!(run.ownership);
1106 }
1107
1108 #[test]
1109 fn health_run_options_score_gate_forces_score() {
1110 let mut input = health_run_input();
1111 input.gates.min_score = Some(90.0);
1112
1113 let run = derive_health_run_options(input);
1114
1115 assert!(run.sections.score);
1116 assert_eq!(run.gates.min_score, Some(90.0));
1117 }
1118
1119 #[test]
1120 fn coverage_root_accepts_posix_absolute() {
1121 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1122 assert!(
1123 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1124 );
1125 }
1126
1127 #[test]
1128 fn coverage_root_rejects_relative() {
1129 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1130 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1131 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1132 }
1133
1134 #[test]
1135 fn coverage_root_accepts_none() {
1136 assert!(validate_coverage_root_absolute(None).is_ok());
1137 }
1138
1139 #[test]
1140 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1141 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1142 }
1143}