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