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 churn_file;
23mod component_rollup;
24mod core_pipeline;
25mod coverage_gaps;
26mod coverage_intelligence;
27mod coverage_settings;
28mod css_analytics;
29mod derived_sections;
30mod execute;
31mod file_scores;
32mod filters;
33mod finding_sort;
34mod findings;
35mod findings_pipeline;
36mod framework_health;
37mod grouping;
38mod health_error;
39mod hotspots;
40mod ignore;
41mod large_functions;
42mod output_build;
43pub mod ownership;
44mod package_json;
45mod pipeline;
46mod react_hooks;
47mod result;
48mod runner;
49mod runtime_filter;
50mod runtime_sections;
51mod scope;
52pub mod scoring;
53pub mod styling_score;
54mod tailwind_theme;
55mod targets;
56mod threshold_overrides;
57mod timings;
58mod vital_data;
59mod vital_signs_scope;
60
61pub use crate::results::HealthAnalysisResult;
62pub use churn_file::validate_health_churn_file;
63pub use css_analytics::StylingAnalysisArtifacts;
64use derived_sections::{
65 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
66};
67use execute::HealthOptions;
68pub use execute::execute_health_inner;
69use file_scores::{
70 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
71 print_slow_churn_note,
72};
73use finding_sort::sort_findings;
74pub use health_error::HealthError;
75pub use hotspots::{
76 TargetChurnEvidence, TargetChurnOptions, TargetChurnOutcome, analyze_target_churn,
77};
78pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
79pub use runner::{
80 run_ungrouped_health, run_ungrouped_health_with_session,
81 run_ungrouped_health_with_session_artifacts,
82};
83use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
84use vital_signs_scope::{
85 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
86 compute_vital_signs_and_counts,
87};
88
89pub(crate) fn build_styling_analysis_artifacts(
90 files: &[crate::discover::DiscoveredFile],
91 config: &fallow_config::ResolvedConfig,
92) -> StylingAnalysisArtifacts {
93 css_analytics::build_styling_analysis_artifacts(files, config)
94}
95
96#[must_use]
98pub fn shared_parse_data_from_artifacts(
99 results: &AnalysisResults,
100 graph: Option<RetainedModuleGraph>,
101 modules: Option<Vec<crate::source::ModuleInfo>>,
102 files: Option<Vec<crate::discover::DiscoveredFile>>,
103 workspaces: Vec<WorkspaceInfo>,
104 script_used_packages: impl IntoIterator<Item = String>,
105) -> Option<HealthSharedParseData> {
106 let (Some(modules), Some(files)) = (modules, files) else {
107 return None;
108 };
109 let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
110 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
111 results: results.clone(),
112 timings: None,
113 graph: Some(graph),
114 modules: None,
115 files: None,
116 script_used_packages: script_used_packages.clone(),
117 file_hashes: FxHashMap::default(),
118 });
119 Some(HealthSharedParseData {
120 files,
121 modules,
122 dead_code_results: Some(results.clone()),
123 workspaces,
124 analysis_output,
125 })
126}
127
128#[must_use]
134pub fn should_precompute_dead_code_analysis(
135 options: &HealthExecutionOptions<'_>,
136 config: &fallow_config::ResolvedConfig,
137) -> bool {
138 let max_crap = options
139 .thresholds
140 .max_crap
141 .unwrap_or(config.health.max_crap);
142 options.file_scores
143 || options.coverage_gaps
144 || options.config_activates_coverage_gaps
145 || options.hotspots
146 || options.targets
147 || options.force_full
148 || max_crap > 0.0
149 || options.runtime_coverage.is_some()
150}
151
152pub trait HealthGroupResolver {
158 fn mode_label(&self) -> &'static str;
160 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
162 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
164}
165
166#[derive(Debug, Clone, Copy)]
169pub enum NoGroupResolver {}
170
171#[expect(
172 clippy::uninhabited_references,
173 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
174)]
175impl HealthGroupResolver for NoGroupResolver {
176 fn mode_label(&self) -> &'static str {
177 match *self {}
178 }
179 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
180 match *self {}
181 }
182 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
183 match *self {}
184 }
185}
186
187pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
199 + 'a;
200
201pub struct RuntimeCoverageSeamInput<'a> {
203 pub root: &'a Path,
204 pub modules: &'a [fallow_types::extract::ModuleInfo],
205 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
206 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
207 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
208 pub ignore_set: &'a globset::GlobSet,
209 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
210 pub ws_roots: Option<&'a [PathBuf]>,
211 pub top: Option<usize>,
212 pub codeowners_path: Option<&'a str>,
213 pub quiet: bool,
214 pub output: OutputFormat,
215}
216
217pub struct HealthSeams<'a> {
221 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
223 pub note_graph_structure: &'a dyn Fn(usize, usize),
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum HealthSort {
232 Severity,
233 Cyclomatic,
234 Cognitive,
235 Lines,
236}
237
238#[derive(Debug, Clone, Copy, Default, PartialEq)]
240pub struct HealthThresholdOverrides {
241 pub max_cyclomatic: Option<u16>,
242 pub max_cognitive: Option<u16>,
243 pub max_crap: Option<f64>,
246}
247
248#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
250pub struct HealthCoverageInputs<'a> {
251 pub coverage: Option<&'a Path>,
252 pub coverage_root: Option<&'a Path>,
255}
256
257pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
264 if let Some(path) = coverage_root
265 && !is_absolute_path_any_platform(path)
266 {
267 return Err(format!(
268 "--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'.",
269 path.display()
270 ));
271 }
272 Ok(())
273}
274
275#[derive(Debug, Clone, Copy, Default, PartialEq)]
277pub struct HealthGateOptions {
278 pub min_score: Option<f64>,
279 pub min_severity: Option<FindingSeverity>,
280 pub report_only: bool,
282}
283
284#[derive(Debug, Clone)]
286pub struct HealthSectionOptions {
287 output: OutputFormat,
288 complexity: bool,
289 file_scores: bool,
290 coverage_gaps: bool,
291 hotspots: bool,
292 targets: bool,
293 css: bool,
294 score: bool,
295 score_gate: bool,
296 snapshot_requested: bool,
297 trend: bool,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub struct DerivedHealthSections {
303 pub any_section: bool,
304 pub complexity: bool,
305 pub file_scores: bool,
306 pub coverage_gaps: bool,
307 pub hotspots: bool,
308 pub targets: bool,
309 pub css: bool,
310 pub score: bool,
311 pub force_full: bool,
312 pub score_only_output: bool,
313}
314
315#[derive(Debug, Clone)]
318pub struct HealthRunOptionsInput<'a> {
319 pub output: OutputFormat,
320 pub thresholds: HealthThresholdOverrides,
321 pub top: Option<usize>,
322 pub sort: HealthSort,
323 pub complexity: bool,
324 pub file_scores: bool,
325 pub coverage_gaps: bool,
326 pub hotspots: bool,
327 pub ownership: bool,
328 pub ownership_emails: Option<EmailMode>,
329 pub targets: bool,
330 pub css: bool,
331 pub effort: Option<EffortEstimate>,
332 pub score: bool,
333 pub gates: HealthGateOptions,
334 pub snapshot_requested: bool,
335 pub trend: bool,
336 pub since: Option<&'a str>,
337 pub min_commits: Option<u32>,
338 pub coverage_inputs: HealthCoverageInputs<'a>,
339 pub runtime_coverage: Option<RuntimeCoverageOptions>,
340}
341
342#[derive(Debug, Clone)]
344pub struct HealthRunOptions<'a> {
345 pub thresholds: HealthThresholdOverrides,
346 pub top: Option<usize>,
347 pub sort: HealthSort,
348 pub sections: DerivedHealthSections,
349 pub ownership: bool,
350 pub ownership_emails: Option<EmailMode>,
351 pub effort: Option<EffortEstimate>,
352 pub gates: HealthGateOptions,
353 pub since: Option<&'a str>,
354 pub min_commits: Option<u32>,
355 pub coverage_inputs: HealthCoverageInputs<'a>,
356 pub runtime_coverage: Option<RuntimeCoverageOptions>,
357}
358
359#[derive(Debug, Clone)]
363pub struct HealthExecutionOptions<'a> {
364 pub root: &'a Path,
365 pub config_path: &'a Option<PathBuf>,
366 pub output: OutputFormat,
367 pub no_cache: bool,
368 pub threads: usize,
369 pub quiet: bool,
370 pub complexity_breakdown: bool,
375 pub thresholds: HealthThresholdOverrides,
376 pub top: Option<usize>,
377 pub sort: HealthSort,
378 pub production: bool,
379 pub production_override: Option<bool>,
380 pub allow_remote_extends: bool,
381 pub changed_since: Option<&'a str>,
382 pub diff_index: Option<&'a DiffIndex>,
383 pub use_shared_diff_index: bool,
384 pub workspace: Option<&'a [String]>,
385 pub changed_workspaces: Option<&'a str>,
386 pub baseline: Option<&'a Path>,
387 pub save_baseline: Option<&'a Path>,
388 pub baseline_mode: crate::baseline::HealthBaselineMode,
393 pub baseline_mode_explicit: bool,
399 pub complexity: bool,
400 pub file_scores: bool,
401 pub coverage_gaps: bool,
402 pub config_activates_coverage_gaps: bool,
403 pub hotspots: bool,
404 pub ownership: bool,
405 pub ownership_emails: Option<EmailMode>,
406 pub targets: bool,
407 pub css: bool,
408 pub css_deep: bool,
409 pub force_full: bool,
410 pub score_only_output: bool,
411 pub enforce_coverage_gap_gate: bool,
412 pub effort: Option<EffortEstimate>,
413 pub score: bool,
414 pub gates: HealthGateOptions,
415 pub since: Option<&'a str>,
416 pub min_commits: Option<u32>,
417 pub explain: bool,
418 pub summary: bool,
419 pub save_snapshot: Option<PathBuf>,
420 pub trend: bool,
421 pub coverage_inputs: HealthCoverageInputs<'a>,
422 pub performance: bool,
423 pub runtime_coverage: Option<RuntimeCoverageOptions>,
424 pub churn_file: Option<&'a Path>,
425 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
427 pub group_by: Option<GroupByMode>,
429}
430
431#[must_use]
433fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
434 let score = options.score
435 || options.score_gate
436 || options.trend
437 || matches!(options.output, OutputFormat::Badge);
438 let any_section = options.complexity
439 || options.file_scores
440 || options.coverage_gaps
441 || options.hotspots
442 || options.targets
443 || score;
444 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
445 let force_full = options.snapshot_requested || effective_score;
446
447 DerivedHealthSections {
448 any_section,
449 complexity: if any_section {
450 options.complexity
451 } else {
452 true
453 },
454 file_scores: if any_section {
455 options.file_scores
456 } else {
457 true
458 } || force_full,
459 coverage_gaps: if any_section {
460 options.coverage_gaps
461 } else {
462 false
463 },
464 hotspots: if any_section { options.hotspots } else { true }
465 || options.snapshot_requested
466 || options.trend,
467 targets: if any_section { options.targets } else { true },
468 css: options.css,
469 score: effective_score,
470 force_full,
471 score_only_output: is_health_score_only_output(options, score),
472 }
473}
474
475#[must_use]
477pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
478 let targets = input.targets || input.effort.is_some();
479 let sections = derive_health_sections(&HealthSectionOptions {
480 output: input.output,
481 complexity: input.complexity,
482 file_scores: input.file_scores,
483 coverage_gaps: input.coverage_gaps,
484 hotspots: input.hotspots,
485 targets,
486 css: input.css,
487 score: input.score,
488 score_gate: input.gates.min_score.is_some(),
489 snapshot_requested: input.snapshot_requested,
490 trend: input.trend,
491 });
492
493 HealthRunOptions {
494 thresholds: input.thresholds,
495 top: input.top,
496 sort: input.sort,
497 sections,
498 ownership: input.ownership && sections.hotspots,
499 ownership_emails: input.ownership_emails,
500 effort: input.effort,
501 gates: input.gates,
502 since: input.since,
503 min_commits: input.min_commits,
504 coverage_inputs: input.coverage_inputs,
505 runtime_coverage: input.runtime_coverage,
506 }
507}
508
509fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
510 score
511 && !options.complexity
512 && !options.file_scores
513 && !options.coverage_gaps
514 && !options.hotspots
515 && !options.targets
516 && !options.trend
517}
518
519#[derive(Debug, Clone)]
521pub struct ComplexitySectionOptions {
522 complexity: bool,
523 file_scores: bool,
524 coverage_gaps: bool,
525 hotspots: bool,
526 ownership: bool,
527 targets: bool,
528 css: bool,
529 score: bool,
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub struct DerivedComplexityOptions {
535 any_section: bool,
536 complexity: bool,
537 file_scores: bool,
538 coverage_gaps: bool,
539 hotspots: bool,
540 ownership: bool,
541 targets: bool,
542 force_full: bool,
543 score_only_output: bool,
544 score: bool,
545}
546
547#[must_use]
549pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
550 let requested_hotspots = options.hotspots || options.ownership;
551 let sections = derive_health_sections(&HealthSectionOptions {
552 output: OutputFormat::Human,
553 complexity: options.complexity,
554 file_scores: options.file_scores,
555 coverage_gaps: options.coverage_gaps,
556 hotspots: requested_hotspots,
557 targets: options.targets,
558 css: options.css,
559 score: options.score,
560 score_gate: false,
561 snapshot_requested: false,
562 trend: false,
563 });
564
565 DerivedComplexityOptions {
566 any_section: sections.any_section,
567 complexity: sections.complexity,
568 file_scores: sections.file_scores,
569 coverage_gaps: sections.coverage_gaps,
570 hotspots: sections.hotspots,
571 ownership: options.ownership && sections.hotspots,
572 targets: sections.targets,
573 force_full: sections.force_full,
574 score_only_output: sections.score_only_output,
575 score: sections.score,
576 }
577}
578
579#[derive(Debug, Clone, PartialEq)]
582pub struct ComplexityRunOptions<'a> {
583 thresholds: HealthThresholdOverrides,
584 top: Option<usize>,
585 sort: HealthSort,
586 complexity_breakdown: bool,
587 sections: DerivedComplexityOptions,
588 ownership_emails: Option<EmailMode>,
589 effort: Option<EffortEstimate>,
590 css: bool,
591 since: Option<&'a str>,
592 min_commits: Option<u32>,
593 coverage_inputs: HealthCoverageInputs<'a>,
594}
595
596#[derive(Debug, Clone)]
598pub struct RuntimeCoverageOptions {
599 pub path: PathBuf,
600 pub min_invocations_hot: u64,
601 pub min_observation_volume: Option<u32>,
606 pub low_traffic_threshold: Option<f64>,
610 pub license_jwt: String,
611 pub watermark: Option<RuntimeCoverageWatermark>,
612}
613
614pub struct HealthSharedParseData {
616 pub files: Vec<fallow_types::discover::DiscoveredFile>,
617 pub modules: Vec<fallow_types::extract::ModuleInfo>,
618 pub dead_code_results: Option<AnalysisResults>,
620 pub workspaces: Vec<WorkspaceInfo>,
621 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn health_run_input() -> HealthRunOptionsInput<'static> {
630 HealthRunOptionsInput {
631 output: OutputFormat::Json,
632 thresholds: HealthThresholdOverrides::default(),
633 top: None,
634 sort: HealthSort::Cyclomatic,
635 complexity: false,
636 file_scores: false,
637 coverage_gaps: false,
638 hotspots: false,
639 ownership: false,
640 ownership_emails: None,
641 targets: false,
642 css: false,
643 effort: None,
644 score: false,
645 gates: HealthGateOptions::default(),
646 snapshot_requested: false,
647 trend: false,
648 since: None,
649 min_commits: None,
650 coverage_inputs: HealthCoverageInputs::default(),
651 runtime_coverage: None,
652 }
653 }
654
655 #[test]
656 fn health_execution_options_own_shared_runner_scope() {
657 let root = Path::new("/project");
658 let config_path = None;
659 let workspace = vec!["packages/app".to_string()];
660 let diff = DiffIndex::from_unified_diff(
661 "diff --git a/src/a.ts b/src/a.ts\n\
662 --- a/src/a.ts\n\
663 +++ b/src/a.ts\n\
664 @@ -0,0 +1,1 @@\n\
665 +new line\n",
666 );
667 let runtime_coverage = RuntimeCoverageOptions {
668 path: PathBuf::from("coverage/v8"),
669 min_invocations_hot: 10,
670 min_observation_volume: Some(500),
671 low_traffic_threshold: Some(0.01),
672 license_jwt: "test.jwt".to_string(),
673 watermark: None,
674 };
675
676 let options = HealthExecutionOptions {
677 root,
678 config_path: &config_path,
679 output: OutputFormat::Json,
680 no_cache: true,
681 threads: 2,
682 quiet: true,
683 complexity_breakdown: true,
684 thresholds: HealthThresholdOverrides::default(),
685 top: Some(5),
686 sort: HealthSort::Cognitive,
687 production: true,
688 production_override: Some(true),
689 allow_remote_extends: false,
690 changed_since: Some("HEAD~1"),
691 diff_index: Some(&diff),
692 use_shared_diff_index: false,
693 workspace: Some(&workspace),
694 changed_workspaces: None,
695 baseline: Some(Path::new(".fallow/health-baseline.json")),
696 save_baseline: None,
697 baseline_mode: crate::baseline::HealthBaselineMode::Count,
698 baseline_mode_explicit: false,
699 complexity: true,
700 file_scores: true,
701 coverage_gaps: false,
702 config_activates_coverage_gaps: false,
703 hotspots: true,
704 ownership: false,
705 ownership_emails: None,
706 targets: true,
707 css: false,
708 css_deep: false,
709 force_full: true,
710 score_only_output: false,
711 enforce_coverage_gap_gate: true,
712 effort: Some(EffortEstimate::Low),
713 score: true,
714 gates: HealthGateOptions {
715 min_score: Some(80.0),
716 min_severity: None,
717 report_only: false,
718 },
719 since: Some("30d"),
720 min_commits: Some(2),
721 explain: true,
722 summary: false,
723 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
724 trend: true,
725 coverage_inputs: HealthCoverageInputs::default(),
726 performance: true,
727 runtime_coverage: Some(runtime_coverage),
728 churn_file: Some(Path::new("churn.json")),
729 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
730 group_by: Some(GroupByMode::Directory),
731 };
732
733 assert_eq!(options.root, root);
734 assert!(
735 options
736 .diff_index
737 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
738 );
739 assert_eq!(options.workspace, Some(workspace.as_slice()));
740 assert!(options.runtime_coverage.is_some());
741 assert_eq!(options.group_by, Some(GroupByMode::Directory));
742 assert_eq!(
743 options.save_snapshot.as_deref(),
744 Some(Path::new(".fallow/snapshots/health.json"))
745 );
746 }
747
748 #[test]
749 fn health_run_options_default_sections_match_health_defaults() {
750 let run = derive_health_run_options(health_run_input());
751
752 assert!(run.sections.complexity);
753 assert!(run.sections.file_scores);
754 assert!(run.sections.hotspots);
755 assert!(run.sections.targets);
756 assert!(run.sections.score);
757 assert!(!run.ownership);
758 }
759
760 #[test]
761 fn health_run_options_effort_requests_targets() {
762 let mut input = health_run_input();
763 input.effort = Some(EffortEstimate::Low);
764
765 let run = derive_health_run_options(input);
766
767 assert!(run.sections.targets);
768 assert_eq!(run.effort, Some(EffortEstimate::Low));
769 }
770
771 struct HealthExecutionOptionsFixture {
772 config_path: Option<PathBuf>,
773 }
774
775 impl HealthExecutionOptionsFixture {
776 const fn new() -> Self {
777 Self { config_path: None }
778 }
779
780 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
781 HealthExecutionOptions {
782 root,
783 config_path: &self.config_path,
784 output: OutputFormat::Human,
785 no_cache: true,
786 threads: 1,
787 quiet: true,
788 complexity_breakdown: false,
789 thresholds: HealthThresholdOverrides::default(),
790 top: None,
791 sort: HealthSort::Cyclomatic,
792 production: false,
793 production_override: None,
794 allow_remote_extends: false,
795 changed_since: None,
796 diff_index: None,
797 use_shared_diff_index: false,
798 workspace: None,
799 changed_workspaces: None,
800 baseline: None,
801 save_baseline: None,
802 baseline_mode: crate::baseline::HealthBaselineMode::Count,
803 baseline_mode_explicit: false,
804 complexity: true,
805 file_scores: false,
806 coverage_gaps: false,
807 config_activates_coverage_gaps: false,
808 hotspots: false,
809 ownership: false,
810 ownership_emails: None,
811 targets: false,
812 css: false,
813 css_deep: false,
814 force_full: false,
815 score_only_output: false,
816 enforce_coverage_gap_gate: true,
817 effort: None,
818 score: false,
819 gates: HealthGateOptions::default(),
820 since: None,
821 min_commits: None,
822 explain: false,
823 summary: false,
824 save_snapshot: None,
825 trend: false,
826 coverage_inputs: HealthCoverageInputs::default(),
827 performance: false,
828 runtime_coverage: None,
829 churn_file: None,
830 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
831 group_by: None,
832 }
833 }
834 }
835
836 #[test]
837 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
838 let project = tempfile::tempdir().expect("temp dir");
839 let fixture = HealthExecutionOptionsFixture::new();
840 let options = fixture.options(project.path());
841 let config = crate::project_config::default_project_config(project.path()).config;
842
843 assert!(should_precompute_dead_code_analysis(&options, &config));
844 }
845
846 #[test]
847 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
848 let project = tempfile::tempdir().expect("temp dir");
849 let fixture = HealthExecutionOptionsFixture::new();
850 let mut options = fixture.options(project.path());
851 options.thresholds.max_crap = Some(0.0);
852 let config = crate::project_config::default_project_config(project.path()).config;
853
854 assert!(!should_precompute_dead_code_analysis(&options, &config));
855 }
856
857 #[test]
858 fn standalone_health_precomputes_dead_code_for_target_sections() {
859 let project = tempfile::tempdir().expect("temp dir");
860 let fixture = HealthExecutionOptionsFixture::new();
861 let mut options = fixture.options(project.path());
862 options.thresholds.max_crap = Some(0.0);
863 options.targets = true;
864 let config = crate::project_config::default_project_config(project.path()).config;
865
866 assert!(should_precompute_dead_code_analysis(&options, &config));
867 }
868
869 #[test]
870 fn health_run_options_ownership_requires_hotspots() {
871 let mut input = health_run_input();
872 input.complexity = true;
873 input.ownership = true;
874
875 let run = derive_health_run_options(input);
876
877 assert!(!run.sections.hotspots);
878 assert!(!run.ownership);
879
880 let mut input = health_run_input();
881 input.ownership = true;
882 input.hotspots = true;
883
884 let run = derive_health_run_options(input);
885
886 assert!(run.sections.hotspots);
887 assert!(run.ownership);
888 }
889
890 #[test]
891 fn health_run_options_score_gate_forces_score() {
892 let mut input = health_run_input();
893 input.gates.min_score = Some(90.0);
894
895 let run = derive_health_run_options(input);
896
897 assert!(run.sections.score);
898 assert_eq!(run.gates.min_score, Some(90.0));
899 }
900
901 #[test]
902 fn coverage_root_accepts_posix_absolute() {
903 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
904 assert!(
905 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
906 );
907 }
908
909 #[test]
910 fn coverage_root_rejects_relative() {
911 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
912 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
913 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
914 }
915
916 #[test]
917 fn coverage_root_accepts_none() {
918 assert!(validate_coverage_root_absolute(None).is_ok());
919 }
920
921 #[test]
922 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
923 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
924 }
925}