Skip to main content

fallow_engine/health/
mod.rs

1//! Command-neutral health execution options and runners.
2
3use 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/// Build health shared parse data from retained dead-code artifacts.
97#[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/// Return true when health sections will need dead-code analysis artifacts.
129///
130/// Callers that already have a session and parsed modules can precompute these
131/// artifacts once, then pass them into [`HealthPipelineInputs`] to avoid a
132/// second graph and dead-code analysis inside the health pipeline.
133#[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
152/// Command-neutral grouping resolver contract for `--group-by` health output.
153///
154/// The CLI owns the concrete resolver (CODEOWNERS parsing, package discovery);
155/// the engine grouping pass only needs these three read operations, so it stays
156/// generic over the resolver instead of depending on the CLI type.
157pub trait HealthGroupResolver {
158    /// Stable label for the active grouping mode (`owner` / `directory` / ...).
159    fn mode_label(&self) -> &'static str;
160    /// Resolve a repo-relative path to its group key and the matching rule.
161    fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
162    /// Section owners for the group a path belongs to, when known.
163    fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
164}
165
166/// Placeholder grouping resolver for runs without `--group-by` (the programmatic
167/// API path). Constructed only as `None`, so its methods are never invoked.
168#[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
187/// Runtime coverage analysis seam.
188///
189/// Runtime coverage execution drives the closed-source `fallow-cov` sidecar
190/// (license verification, subprocess spawning), which stays in the CLI. The
191/// engine calls this callback only when [`HealthExecutionOptions::runtime_coverage`]
192/// is set, so the default and programmatic paths never touch it.
193///
194/// The seam prints its own errors (license / sidecar diagnostics), so it returns
195/// the already-printed exit code as a bare `u8`. The engine wraps that code in
196/// [`HealthError::Printed`] so the CLI boundary honors the code without emitting
197/// a second error document.
198pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
199    + 'a;
200
201/// Inputs the runtime coverage seam needs from the analysis core.
202pub 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
217/// CLI-supplied callbacks the command-neutral health pipeline needs.
218///
219/// The pipeline itself stays cli-free; these are the seams the CLI threads in.
220pub struct HealthSeams<'a> {
221    /// Runs the runtime coverage sidecar (only when runtime coverage is set).
222    pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
223    /// Records module-graph structure facts (graph node count, edge count) into
224    /// the CLI's process-global telemetry sinks. Best-effort; the engine never
225    /// owns telemetry state.
226    pub note_graph_structure: &'a dyn Fn(usize, usize),
227}
228
229/// Command-neutral sort criteria for health complexity findings.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum HealthSort {
232    Severity,
233    Cyclomatic,
234    Cognitive,
235    Lines,
236}
237
238/// Command-neutral threshold overrides for health complexity findings.
239#[derive(Debug, Clone, Copy, Default, PartialEq)]
240pub struct HealthThresholdOverrides {
241    pub max_cyclomatic: Option<u16>,
242    pub max_cognitive: Option<u16>,
243    /// Maximum CRAP score threshold. Functions meeting or exceeding this score
244    /// are reported as complexity findings.
245    pub max_crap: Option<f64>,
246}
247
248/// Command-neutral Istanbul coverage inputs for health CRAP scoring.
249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
250pub struct HealthCoverageInputs<'a> {
251    pub coverage: Option<&'a Path>,
252    /// Absolute coverage-path prefix to strip before rebasing files onto the
253    /// project root.
254    pub coverage_root: Option<&'a Path>,
255}
256
257/// Validate that a coverage-data root is absolute under Unix or Windows path
258/// conventions.
259///
260/// Istanbul coverage paths often come from a Linux CI runner even when fallow
261/// is invoked on another host, so POSIX-rooted paths and Windows drive paths
262/// are both accepted on every platform.
263pub 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/// Command-neutral health exit gate options.
276#[derive(Debug, Clone, Copy, Default, PartialEq)]
277pub struct HealthGateOptions {
278    pub min_score: Option<f64>,
279    pub min_severity: Option<FindingSeverity>,
280    /// Render the score and findings but never fail CI on a health gate.
281    pub report_only: bool,
282}
283
284/// Input for deriving effective health sections from command-neutral flags.
285#[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/// Derived section selection for health runs.
301#[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/// Command-neutral inputs used to normalize a health run before it reaches a
316/// concrete runner.
317#[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/// Normalized health inputs shared by CLI, API, NAPI, and future runners.
343#[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/// Command-neutral inputs needed to execute a health analysis.
360///
361/// These fields are shared runner inputs rather than rendering concerns.
362#[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    /// Include per-decision-point complexity contributions in typed findings.
371    ///
372    /// This changes the produced health result shape, so it belongs to the
373    /// runner input contract rather than CLI rendering options.
374    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    /// Controls both halves of the baseline lifecycle: which buckets
389    /// `save_baseline` writes and how a loaded `baseline` is matched. An
390    /// identity save writes count and identity buckets, a count save writes
391    /// count buckets only.
392    pub baseline_mode: crate::baseline::HealthBaselineMode,
393    /// Whether `baseline_mode` was requested explicitly rather than defaulted.
394    /// A defaulted count save refuses to overwrite a baseline that carries
395    /// identity buckets, because dropping them breaks later identity-mode
396    /// comparisons; an explicit count request is treated as intent to
397    /// downgrade.
398    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    /// Compatibility identity persisted with snapshots and checked by trends.
426    pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
427    /// Optional grouping mode for typed health output.
428    pub group_by: Option<GroupByMode>,
429}
430
431/// Derive effective health section flags for CLI and embedders.
432#[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/// Normalize health run inputs into the engine-owned run contract.
476#[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/// Input for deriving effective programmatic complexity sections.
520#[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/// Derived section selection for programmatic health / complexity runs.
533#[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/// Derive effective programmatic health / complexity section flags.
548#[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/// Normalized programmatic complexity / health inputs shared by API, NAPI, and
580/// engine-backed runners.
581#[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/// Command-neutral runtime coverage input for health analysis.
597#[derive(Debug, Clone)]
598pub struct RuntimeCoverageOptions {
599    pub path: PathBuf,
600    pub min_invocations_hot: u64,
601    /// Minimum total trace volume before high-confidence `safe_to_delete` /
602    /// `review_required` verdicts may be emitted. Below this the sidecar caps
603    /// confidence at `medium`. `None` lets the sidecar use its spec-default
604    /// (5000).
605    pub min_observation_volume: Option<u32>,
606    /// Fraction of total trace count below which an invoked function is
607    /// classified as `low_traffic` rather than `active`. `None` lets the
608    /// sidecar use its spec-default (0.001 = 0.1%).
609    pub low_traffic_threshold: Option<f64>,
610    pub license_jwt: String,
611    pub watermark: Option<RuntimeCoverageWatermark>,
612}
613
614/// Pre-parsed health input reused from another analysis in the same process.
615pub struct HealthSharedParseData {
616    pub files: Vec<fallow_types::discover::DiscoveredFile>,
617    pub modules: Vec<fallow_types::extract::ModuleInfo>,
618    /// Dead-code results reused by advisory health surfaces that do not need the graph.
619    pub dead_code_results: Option<AnalysisResults>,
620    pub workspaces: Vec<WorkspaceInfo>,
621    /// Full analysis output (graph + results) for file scoring.
622    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}