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 css_analytics;
31mod derived_sections;
32pub(crate) mod diagnostics;
33mod execute;
34mod file_scores;
35mod filters;
36mod finding_sort;
37mod findings;
38mod findings_pipeline;
39mod framework_health;
40mod grouping;
41mod health_error;
42mod hotspots;
43mod ignore;
44mod large_functions;
45mod output_build;
46pub mod ownership;
47mod package_json;
48mod pipeline;
49mod react_hooks;
50mod result;
51mod runner;
52mod runtime_filter;
53mod runtime_sections;
54mod scope;
55pub mod scoring;
58pub mod styling_score;
59mod tailwind_theme;
60mod targets;
61mod threshold_overrides;
62mod timings;
63mod vital_data;
64mod vital_signs_scope;
65
66pub use crate::results::HealthAnalysisResult;
67pub use churn_file::validate_health_churn_file;
68pub use css_analytics::StylingAnalysisArtifacts;
69use derived_sections::{
70 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
71};
72use execute::HealthOptions;
73pub use execute::execute_health_inner;
74use file_scores::{
75 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
76 print_slow_churn_note,
77};
78use finding_sort::sort_findings;
79pub use health_error::HealthError;
80pub use hotspots::{
81 TargetChurnEvidence, TargetChurnOptions, TargetChurnOutcome, analyze_target_churn,
82};
83pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
84pub use runner::{
85 run_ungrouped_health, run_ungrouped_health_with_session,
86 run_ungrouped_health_with_session_artifacts,
87};
88use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
89use vital_signs_scope::{
90 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
91 compute_vital_signs_and_counts,
92};
93
94pub(crate) fn build_styling_analysis_artifacts(
95 files: &[crate::discover::DiscoveredFile],
96 modules: &[crate::source::ModuleInfo],
97 config: &fallow_config::ResolvedConfig,
98) -> StylingAnalysisArtifacts {
99 css_analytics::build_styling_analysis_artifacts(files, modules, config)
100}
101
102#[must_use]
104pub fn shared_parse_data_from_artifacts(
105 results: &AnalysisResults,
106 graph: Option<RetainedModuleGraph>,
107 modules: Option<Vec<crate::source::ModuleInfo>>,
108 files: Option<Vec<crate::discover::DiscoveredFile>>,
109 workspaces: Vec<WorkspaceInfo>,
110 script_used_packages: impl IntoIterator<Item = String>,
111) -> Option<HealthSharedParseData> {
112 let (Some(modules), Some(files)) = (modules, files) else {
113 return None;
114 };
115 let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
116 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
117 results: results.clone(),
118 timings: None,
119 graph: Some(graph),
120 modules: None,
121 files: None,
122 script_used_packages: script_used_packages.clone(),
123 file_hashes: FxHashMap::default(),
124 });
125 Some(HealthSharedParseData {
126 files,
127 modules,
128 dead_code_results: Some(results.clone()),
129 workspaces,
130 analysis_output,
131 })
132}
133
134#[must_use]
140pub fn should_precompute_dead_code_analysis(
141 options: &HealthExecutionOptions<'_>,
142 config: &fallow_config::ResolvedConfig,
143) -> bool {
144 let max_crap = options
145 .thresholds
146 .max_crap
147 .unwrap_or(config.health.max_crap);
148 options.file_scores
149 || options.coverage_gaps
150 || options.config_activates_coverage_gaps
151 || options.hotspots
152 || options.targets
153 || options.force_full
154 || max_crap > 0.0
155 || options.runtime_coverage.is_some()
156}
157
158pub trait HealthGroupResolver {
164 fn mode_label(&self) -> &'static str;
166 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
168 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
170}
171
172#[derive(Debug, Clone, Copy)]
175pub enum NoGroupResolver {}
176
177#[expect(
178 clippy::uninhabited_references,
179 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
180)]
181impl HealthGroupResolver for NoGroupResolver {
182 fn mode_label(&self) -> &'static str {
183 match *self {}
184 }
185 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
186 match *self {}
187 }
188 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
189 match *self {}
190 }
191}
192
193pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
205 + 'a;
206
207pub struct RuntimeCoverageSeamInput<'a> {
209 pub root: &'a Path,
211 pub modules: &'a [fallow_types::extract::ModuleInfo],
213 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
216 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
218 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
220 pub ignore_set: &'a globset::GlobSet,
222 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
224 pub ws_roots: Option<&'a [PathBuf]>,
226 pub top: Option<usize>,
228 pub codeowners_path: Option<&'a str>,
230 pub quiet: bool,
232 pub output: OutputFormat,
234}
235
236pub struct HealthSeams<'a> {
240 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
242 pub note_graph_structure: &'a dyn Fn(usize, usize),
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum HealthSort {
251 Severity,
254 Cyclomatic,
256 Cognitive,
258 Lines,
260}
261
262#[derive(Debug, Clone, Copy, Default, PartialEq)]
264pub struct HealthThresholdOverrides {
265 pub max_cyclomatic: Option<u16>,
267 pub max_cognitive: Option<u16>,
269 pub max_crap: Option<f64>,
272}
273
274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
276pub struct HealthCoverageInputs<'a> {
277 pub coverage: Option<&'a Path>,
279 pub coverage_root: Option<&'a Path>,
282 pub coverage_relocated: bool,
288}
289
290pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
297 if let Some(path) = coverage_root
298 && !is_absolute_path_any_platform(path)
299 {
300 return Err(format!(
301 "--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'.",
302 path.display()
303 ));
304 }
305 Ok(())
306}
307
308#[derive(Debug, Clone, Copy, Default, PartialEq)]
310pub struct HealthGateOptions {
311 pub min_score: Option<f64>,
313 pub min_severity: Option<FindingSeverity>,
315 pub report_only: bool,
317 pub fail_on_stale_baseline: bool,
319}
320
321#[derive(Debug, Clone)]
323pub struct HealthSectionOptions {
324 output: OutputFormat,
325 complexity: bool,
326 file_scores: bool,
327 coverage_gaps: bool,
328 hotspots: bool,
329 targets: bool,
330 css: bool,
331 score: bool,
332 score_gate: bool,
333 snapshot_requested: bool,
334 trend: bool,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct DerivedHealthSections {
340 pub any_section: bool,
343 pub complexity: bool,
345 pub file_scores: bool,
347 pub coverage_gaps: bool,
349 pub hotspots: bool,
351 pub targets: bool,
353 pub css: bool,
355 pub score: bool,
357 pub force_full: bool,
360 pub score_only_output: bool,
363}
364
365#[derive(Debug, Clone)]
368pub struct HealthRunOptionsInput<'a> {
369 pub output: OutputFormat,
371 pub thresholds: HealthThresholdOverrides,
373 pub top: Option<usize>,
375 pub sort: HealthSort,
377 pub complexity: bool,
379 pub file_scores: bool,
381 pub coverage_gaps: bool,
383 pub hotspots: bool,
385 pub ownership: bool,
387 pub ownership_emails: Option<EmailMode>,
389 pub targets: bool,
391 pub css: bool,
393 pub effort: Option<EffortEstimate>,
395 pub score: bool,
397 pub gates: HealthGateOptions,
399 pub snapshot_requested: bool,
401 pub trend: bool,
403 pub since: Option<&'a str>,
405 pub min_commits: Option<u32>,
407 pub coverage_inputs: HealthCoverageInputs<'a>,
409 pub runtime_coverage: Option<RuntimeCoverageOptions>,
411}
412
413#[derive(Debug, Clone)]
415pub struct HealthRunOptions<'a> {
416 pub thresholds: HealthThresholdOverrides,
418 pub top: Option<usize>,
420 pub sort: HealthSort,
422 pub sections: DerivedHealthSections,
424 pub ownership: bool,
426 pub ownership_emails: Option<EmailMode>,
428 pub effort: Option<EffortEstimate>,
430 pub gates: HealthGateOptions,
432 pub since: Option<&'a str>,
434 pub min_commits: Option<u32>,
436 pub coverage_inputs: HealthCoverageInputs<'a>,
438 pub runtime_coverage: Option<RuntimeCoverageOptions>,
440}
441
442#[derive(Debug, Clone)]
446pub struct HealthExecutionOptions<'a> {
447 pub root: &'a Path,
449 pub config_path: &'a Option<PathBuf>,
451 pub output: OutputFormat,
453 pub no_cache: bool,
455 pub threads: usize,
457 pub quiet: bool,
459 pub complexity_breakdown: bool,
464 pub thresholds: HealthThresholdOverrides,
466 pub top: Option<usize>,
468 pub sort: HealthSort,
470 pub production: bool,
473 pub production_override: Option<bool>,
476 pub allow_remote_extends: bool,
478 pub changed_since: Option<&'a str>,
480 pub diff_index: Option<&'a DiffIndex>,
482 pub use_shared_diff_index: bool,
485 pub workspace: Option<&'a [String]>,
487 pub changed_workspaces: Option<&'a str>,
489 pub scope: Option<PathBuf>,
494 pub baseline: Option<&'a Path>,
496 pub save_baseline: Option<&'a Path>,
498 pub baseline_mode: crate::baseline::HealthBaselineMode,
503 pub baseline_mode_explicit: bool,
509 pub complexity: bool,
511 pub file_scores: bool,
513 pub coverage_gaps: bool,
515 pub config_activates_coverage_gaps: bool,
518 pub hotspots: bool,
520 pub ownership: bool,
522 pub ownership_emails: Option<EmailMode>,
524 pub targets: bool,
526 pub css: bool,
528 pub css_deep: bool,
530 pub force_full: bool,
533 pub score_only_output: bool,
536 pub enforce_coverage_gap_gate: bool,
538 pub effort: Option<EffortEstimate>,
540 pub score: bool,
542 pub gates: HealthGateOptions,
544 pub since: Option<&'a str>,
546 pub min_commits: Option<u32>,
548 pub explain: bool,
550 pub summary: bool,
552 pub save_snapshot: Option<PathBuf>,
554 pub trend: bool,
556 pub coverage_inputs: HealthCoverageInputs<'a>,
558 pub performance: bool,
560 pub runtime_coverage: Option<RuntimeCoverageOptions>,
562 pub churn_file: Option<&'a Path>,
564 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
566 pub group_by: Option<GroupByMode>,
568}
569
570#[must_use]
572fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
573 let score = options.score
574 || options.score_gate
575 || options.trend
576 || matches!(options.output, OutputFormat::Badge);
577 let any_section = options.complexity
578 || options.file_scores
579 || options.coverage_gaps
580 || options.hotspots
581 || options.targets
582 || score;
583 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
584 let force_full = options.snapshot_requested || effective_score;
585
586 DerivedHealthSections {
587 any_section,
588 complexity: if any_section {
589 options.complexity
590 } else {
591 true
592 },
593 file_scores: if any_section {
594 options.file_scores
595 } else {
596 true
597 } || force_full,
598 coverage_gaps: if any_section {
599 options.coverage_gaps
600 } else {
601 false
602 },
603 hotspots: if any_section { options.hotspots } else { true }
604 || options.snapshot_requested
605 || options.trend,
606 targets: if any_section { options.targets } else { true },
607 css: options.css,
608 score: effective_score,
609 force_full,
610 score_only_output: is_health_score_only_output(options, score),
611 }
612}
613
614#[must_use]
616pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
617 let targets = input.targets || input.effort.is_some();
618 let sections = derive_health_sections(&HealthSectionOptions {
619 output: input.output,
620 complexity: input.complexity,
621 file_scores: input.file_scores,
622 coverage_gaps: input.coverage_gaps,
623 hotspots: input.hotspots,
624 targets,
625 css: input.css,
626 score: input.score,
627 score_gate: input.gates.min_score.is_some(),
628 snapshot_requested: input.snapshot_requested,
629 trend: input.trend,
630 });
631
632 HealthRunOptions {
633 thresholds: input.thresholds,
634 top: input.top,
635 sort: input.sort,
636 sections,
637 ownership: input.ownership && sections.hotspots,
638 ownership_emails: input.ownership_emails,
639 effort: input.effort,
640 gates: input.gates,
641 since: input.since,
642 min_commits: input.min_commits,
643 coverage_inputs: input.coverage_inputs,
644 runtime_coverage: input.runtime_coverage,
645 }
646}
647
648fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
649 score
650 && !options.complexity
651 && !options.file_scores
652 && !options.coverage_gaps
653 && !options.hotspots
654 && !options.targets
655 && !options.trend
656}
657
658#[derive(Debug, Clone)]
660pub struct ComplexitySectionOptions {
661 complexity: bool,
662 file_scores: bool,
663 coverage_gaps: bool,
664 hotspots: bool,
665 ownership: bool,
666 targets: bool,
667 css: bool,
668 score: bool,
669}
670
671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
673pub struct DerivedComplexityOptions {
674 any_section: bool,
675 complexity: bool,
676 file_scores: bool,
677 coverage_gaps: bool,
678 hotspots: bool,
679 ownership: bool,
680 targets: bool,
681 force_full: bool,
682 score_only_output: bool,
683 score: bool,
684}
685
686#[must_use]
688pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
689 let requested_hotspots = options.hotspots || options.ownership;
690 let sections = derive_health_sections(&HealthSectionOptions {
691 output: OutputFormat::Human,
692 complexity: options.complexity,
693 file_scores: options.file_scores,
694 coverage_gaps: options.coverage_gaps,
695 hotspots: requested_hotspots,
696 targets: options.targets,
697 css: options.css,
698 score: options.score,
699 score_gate: false,
700 snapshot_requested: false,
701 trend: false,
702 });
703
704 DerivedComplexityOptions {
705 any_section: sections.any_section,
706 complexity: sections.complexity,
707 file_scores: sections.file_scores,
708 coverage_gaps: sections.coverage_gaps,
709 hotspots: sections.hotspots,
710 ownership: options.ownership && sections.hotspots,
711 targets: sections.targets,
712 force_full: sections.force_full,
713 score_only_output: sections.score_only_output,
714 score: sections.score,
715 }
716}
717
718#[derive(Debug, Clone, PartialEq)]
721pub struct ComplexityRunOptions<'a> {
722 thresholds: HealthThresholdOverrides,
723 top: Option<usize>,
724 sort: HealthSort,
725 complexity_breakdown: bool,
726 sections: DerivedComplexityOptions,
727 ownership_emails: Option<EmailMode>,
728 effort: Option<EffortEstimate>,
729 css: bool,
730 since: Option<&'a str>,
731 min_commits: Option<u32>,
732 coverage_inputs: HealthCoverageInputs<'a>,
733}
734
735#[derive(Debug, Clone)]
737pub struct RuntimeCoverageOptions {
738 pub path: PathBuf,
740 pub min_invocations_hot: u64,
742 pub min_observation_volume: Option<u32>,
747 pub low_traffic_threshold: Option<f64>,
751 pub license_jwt: String,
753 pub watermark: Option<RuntimeCoverageWatermark>,
755}
756
757pub struct HealthSharedParseData {
759 pub files: Vec<fallow_types::discover::DiscoveredFile>,
761 pub modules: Vec<fallow_types::extract::ModuleInfo>,
763 pub dead_code_results: Option<AnalysisResults>,
765 pub workspaces: Vec<WorkspaceInfo>,
767 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 fn health_run_input() -> HealthRunOptionsInput<'static> {
776 HealthRunOptionsInput {
777 output: OutputFormat::Json,
778 thresholds: HealthThresholdOverrides::default(),
779 top: None,
780 sort: HealthSort::Cyclomatic,
781 complexity: false,
782 file_scores: false,
783 coverage_gaps: false,
784 hotspots: false,
785 ownership: false,
786 ownership_emails: None,
787 targets: false,
788 css: false,
789 effort: None,
790 score: false,
791 gates: HealthGateOptions::default(),
792 snapshot_requested: false,
793 trend: false,
794 since: None,
795 min_commits: None,
796 coverage_inputs: HealthCoverageInputs::default(),
797 runtime_coverage: None,
798 }
799 }
800
801 #[test]
802 fn health_execution_options_own_shared_runner_scope() {
803 let root = Path::new("/project");
804 let config_path = None;
805 let workspace = vec!["packages/app".to_string()];
806 let diff = DiffIndex::from_unified_diff(
807 "diff --git a/src/a.ts b/src/a.ts\n\
808 --- a/src/a.ts\n\
809 +++ b/src/a.ts\n\
810 @@ -0,0 +1,1 @@\n\
811 +new line\n",
812 );
813 let runtime_coverage = RuntimeCoverageOptions {
814 path: PathBuf::from("coverage/v8"),
815 min_invocations_hot: 10,
816 min_observation_volume: Some(500),
817 low_traffic_threshold: Some(0.01),
818 license_jwt: "test.jwt".to_string(),
819 watermark: None,
820 };
821
822 let options = HealthExecutionOptions {
823 root,
824 config_path: &config_path,
825 output: OutputFormat::Json,
826 no_cache: true,
827 threads: 2,
828 quiet: true,
829 complexity_breakdown: true,
830 thresholds: HealthThresholdOverrides::default(),
831 top: Some(5),
832 sort: HealthSort::Cognitive,
833 production: true,
834 production_override: Some(true),
835 allow_remote_extends: false,
836 changed_since: Some("HEAD~1"),
837 diff_index: Some(&diff),
838 use_shared_diff_index: false,
839 workspace: Some(&workspace),
840 changed_workspaces: None,
841 scope: None,
842 baseline: Some(Path::new(".fallow/health-baseline.json")),
843 save_baseline: None,
844 baseline_mode: crate::baseline::HealthBaselineMode::Count,
845 baseline_mode_explicit: false,
846 complexity: true,
847 file_scores: true,
848 coverage_gaps: false,
849 config_activates_coverage_gaps: false,
850 hotspots: true,
851 ownership: false,
852 ownership_emails: None,
853 targets: true,
854 css: false,
855 css_deep: false,
856 force_full: true,
857 score_only_output: false,
858 enforce_coverage_gap_gate: true,
859 effort: Some(EffortEstimate::Low),
860 score: true,
861 gates: HealthGateOptions {
862 min_score: Some(80.0),
863 min_severity: None,
864 report_only: false,
865 fail_on_stale_baseline: false,
866 },
867 since: Some("30d"),
868 min_commits: Some(2),
869 explain: true,
870 summary: false,
871 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
872 trend: true,
873 coverage_inputs: HealthCoverageInputs::default(),
874 performance: true,
875 runtime_coverage: Some(runtime_coverage),
876 churn_file: Some(Path::new("churn.json")),
877 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
878 group_by: Some(GroupByMode::Directory),
879 };
880
881 assert_eq!(options.root, root);
882 assert!(
883 options
884 .diff_index
885 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
886 );
887 assert_eq!(options.workspace, Some(workspace.as_slice()));
888 assert!(options.runtime_coverage.is_some());
889 assert_eq!(options.group_by, Some(GroupByMode::Directory));
890 assert_eq!(
891 options.save_snapshot.as_deref(),
892 Some(Path::new(".fallow/snapshots/health.json"))
893 );
894 }
895
896 #[test]
897 fn health_run_options_default_sections_match_health_defaults() {
898 let run = derive_health_run_options(health_run_input());
899
900 assert!(run.sections.complexity);
901 assert!(run.sections.file_scores);
902 assert!(run.sections.hotspots);
903 assert!(run.sections.targets);
904 assert!(run.sections.score);
905 assert!(!run.ownership);
906 }
907
908 #[test]
909 fn health_run_options_effort_requests_targets() {
910 let mut input = health_run_input();
911 input.effort = Some(EffortEstimate::Low);
912
913 let run = derive_health_run_options(input);
914
915 assert!(run.sections.targets);
916 assert_eq!(run.effort, Some(EffortEstimate::Low));
917 }
918
919 struct HealthExecutionOptionsFixture {
920 config_path: Option<PathBuf>,
921 }
922
923 impl HealthExecutionOptionsFixture {
924 const fn new() -> Self {
925 Self { config_path: None }
926 }
927
928 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
929 HealthExecutionOptions {
930 root,
931 config_path: &self.config_path,
932 output: OutputFormat::Human,
933 no_cache: true,
934 threads: 1,
935 quiet: true,
936 complexity_breakdown: false,
937 thresholds: HealthThresholdOverrides::default(),
938 top: None,
939 sort: HealthSort::Cyclomatic,
940 production: false,
941 production_override: None,
942 allow_remote_extends: false,
943 changed_since: None,
944 diff_index: None,
945 use_shared_diff_index: false,
946 workspace: None,
947 changed_workspaces: None,
948 scope: None,
949 baseline: None,
950 save_baseline: None,
951 baseline_mode: crate::baseline::HealthBaselineMode::Count,
952 baseline_mode_explicit: false,
953 complexity: true,
954 file_scores: false,
955 coverage_gaps: false,
956 config_activates_coverage_gaps: false,
957 hotspots: false,
958 ownership: false,
959 ownership_emails: None,
960 targets: false,
961 css: false,
962 css_deep: false,
963 force_full: false,
964 score_only_output: false,
965 enforce_coverage_gap_gate: true,
966 effort: None,
967 score: false,
968 gates: HealthGateOptions::default(),
969 since: None,
970 min_commits: None,
971 explain: false,
972 summary: false,
973 save_snapshot: None,
974 trend: false,
975 coverage_inputs: HealthCoverageInputs::default(),
976 performance: false,
977 runtime_coverage: None,
978 churn_file: None,
979 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
980 group_by: None,
981 }
982 }
983 }
984
985 #[test]
992 fn a_churn_file_that_fails_the_re_read_records_the_skip() {
993 let project = tempfile::tempdir().expect("temp dir");
994 let root = project.path();
995 let churn_file = root.join("churn.json");
996 std::fs::write(&churn_file, "{ not json").expect("churn file");
997 let fixture = HealthExecutionOptionsFixture::new();
998 let mut options = fixture.options(root);
999 options.churn_file = Some(&churn_file);
1000
1001 assert!(
1002 hotspots::fetch_churn_data(&options, &root.join(".fallow")).is_none(),
1003 "an unreadable churn file yields no churn"
1004 );
1005
1006 let recorded = fallow_config::health_stage_workspace_diagnostics(root);
1007 let skip = recorded
1008 .iter()
1009 .find(|entry| entry.kind.id() == "hotspots-skipped")
1010 .expect("the skip is recorded");
1011 assert_eq!(
1012 skip.kind,
1013 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
1014 cause: "churn-file-unreadable".to_owned(),
1015 }
1016 );
1017 assert!(
1018 skip.message.contains("churn.json"),
1019 "the remedy names the file: {}",
1020 skip.message
1021 );
1022 }
1023
1024 #[test]
1025 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
1026 let project = tempfile::tempdir().expect("temp dir");
1027 let fixture = HealthExecutionOptionsFixture::new();
1028 let options = fixture.options(project.path());
1029 let config = crate::project_config::default_project_config(project.path()).config;
1030
1031 assert!(should_precompute_dead_code_analysis(&options, &config));
1032 }
1033
1034 #[test]
1035 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
1036 let project = tempfile::tempdir().expect("temp dir");
1037 let fixture = HealthExecutionOptionsFixture::new();
1038 let mut options = fixture.options(project.path());
1039 options.thresholds.max_crap = Some(0.0);
1040 let config = crate::project_config::default_project_config(project.path()).config;
1041
1042 assert!(!should_precompute_dead_code_analysis(&options, &config));
1043 }
1044
1045 #[test]
1046 fn standalone_health_precomputes_dead_code_for_target_sections() {
1047 let project = tempfile::tempdir().expect("temp dir");
1048 let fixture = HealthExecutionOptionsFixture::new();
1049 let mut options = fixture.options(project.path());
1050 options.thresholds.max_crap = Some(0.0);
1051 options.targets = true;
1052 let config = crate::project_config::default_project_config(project.path()).config;
1053
1054 assert!(should_precompute_dead_code_analysis(&options, &config));
1055 }
1056
1057 #[test]
1058 fn health_run_options_ownership_requires_hotspots() {
1059 let mut input = health_run_input();
1060 input.complexity = true;
1061 input.ownership = true;
1062
1063 let run = derive_health_run_options(input);
1064
1065 assert!(!run.sections.hotspots);
1066 assert!(!run.ownership);
1067
1068 let mut input = health_run_input();
1069 input.ownership = true;
1070 input.hotspots = true;
1071
1072 let run = derive_health_run_options(input);
1073
1074 assert!(run.sections.hotspots);
1075 assert!(run.ownership);
1076 }
1077
1078 #[test]
1079 fn health_run_options_score_gate_forces_score() {
1080 let mut input = health_run_input();
1081 input.gates.min_score = Some(90.0);
1082
1083 let run = derive_health_run_options(input);
1084
1085 assert!(run.sections.score);
1086 assert_eq!(run.gates.min_score, Some(90.0));
1087 }
1088
1089 #[test]
1090 fn coverage_root_accepts_posix_absolute() {
1091 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1092 assert!(
1093 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1094 );
1095 }
1096
1097 #[test]
1098 fn coverage_root_rejects_relative() {
1099 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1100 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1101 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1102 }
1103
1104 #[test]
1105 fn coverage_root_accepts_none() {
1106 assert!(validate_coverage_root_absolute(None).is_ok());
1107 }
1108
1109 #[test]
1110 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1111 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1112 }
1113}