Skip to main content

fallow_cli/health/
mod.rs

1//! `fallow health` complexity / health command.
2//!
3//! The command-neutral analysis pipeline (scoring, hotspots, targets, grouping,
4//! coverage gaps, vital signs, report assembly) lives in
5//! `fallow_engine::health` API. This module owns the CLI orchestration that the
6//! engine intentionally does not: command option validation, workspace /
7//! changed-file / shared-diff scope resolution, CODEOWNERS-backed
8//! grouping-resolver construction, the runtime coverage sidecar seam,
9//! telemetry recording, exit-code gating, and human / machine rendering.
10
11pub mod coverage;
12
13/// Health scoring helpers, re-exported from the engine for CLI consumers that
14/// still address them through `crate::health::scoring`.
15pub use fallow_engine::health::scoring;
16
17use std::process::ExitCode;
18use std::time::Instant;
19
20use colored::Colorize;
21use fallow_config::OutputFormat;
22use fallow_engine::health::{
23    HealthError, HealthExecutionOptions, HealthGateOptions, HealthGroupResolver,
24    HealthPipelineInputs, HealthScopeInputs, HealthSeams, HealthSharedParseData, HealthSort,
25    RuntimeCoverageSeamInput, execute_health_inner, validate_health_churn_file,
26};
27
28use crate::check::{get_changed_files, resolve_workspace_scope};
29use crate::error::emit_error;
30use crate::report;
31use crate::report::OwnershipResolver;
32
33/// Sort criteria for complexity output.
34#[derive(Clone, clap::ValueEnum)]
35pub enum SortBy {
36    Severity,
37    Cyclomatic,
38    Cognitive,
39    Lines,
40}
41
42impl From<SortBy> for HealthSort {
43    fn from(sort: SortBy) -> Self {
44        match sort {
45            SortBy::Severity => Self::Severity,
46            SortBy::Cyclomatic => Self::Cyclomatic,
47            SortBy::Cognitive => Self::Cognitive,
48            SortBy::Lines => Self::Lines,
49        }
50    }
51}
52
53pub type HealthOptions<'a> = HealthExecutionOptions<'a>;
54
55/// CLI-only semantic overlay options for `health --type-coupling`.
56pub struct TypeAwareHealthOptions<'a> {
57    /// CLI override: `Some(true)` for `--type-aware`, `Some(false)` for
58    /// `--no-type-aware`, `None` when neither flag was passed.
59    pub enabled: Option<bool>,
60    pub requested: bool,
61    pub unfiltered: bool,
62    pub projects: &'a [std::path::PathBuf],
63    pub require: Option<fallow_config::TypeAwareRequire>,
64}
65
66impl HealthGroupResolver for OwnershipResolver {
67    fn mode_label(&self) -> &'static str {
68        OwnershipResolver::mode_label(self)
69    }
70
71    fn resolve_with_rule(&self, rel_path: &std::path::Path) -> (String, Option<String>) {
72        OwnershipResolver::resolve_with_rule(self, rel_path)
73    }
74
75    fn section_owners_of(&self, rel_path: &std::path::Path) -> Option<&[String]> {
76        OwnershipResolver::section_owners_of(self, rel_path)
77    }
78}
79
80/// Resolve the diff index for a health run: an explicit `--diff-file` index
81/// wins, otherwise the process-shared diff cache when the caller opted in.
82fn health_diff_index<'a>(opts: &HealthOptions<'a>) -> Option<&'a fallow_output::DiffIndex> {
83    match opts.diff_index {
84        Some(index) => Some(index),
85        None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(),
86        None => None,
87    }
88}
89
90/// Build the CODEOWNERS / package-backed grouping resolver for `--group-by`.
91fn build_health_group_resolver(
92    opts: &HealthOptions<'_>,
93    config: &fallow_config::ResolvedConfig,
94) -> Result<Option<OwnershipResolver>, ExitCode> {
95    crate::runtime_support::build_ownership_resolver_for_mode(
96        opts.group_by,
97        opts.root,
98        config.codeowners.as_deref(),
99        opts.output,
100    )
101}
102
103/// Record health telemetry from the finished report. Mirrors the per-analysis
104/// telemetry the other commands record; lives in the CLI because the telemetry
105/// sinks are process-global CLI state.
106fn record_health_telemetry(report: &fallow_output::HealthReport, coverage_gaps_has_findings: bool) {
107    if coverage_gaps_has_findings && report.findings.is_empty() {
108        crate::telemetry::note_findings_present(true);
109    } else {
110        crate::telemetry::note_result_count(report.findings.len());
111    }
112    crate::telemetry::note_analysis_scale(
113        Some(report.summary.files_analyzed),
114        Some(report.summary.functions_analyzed),
115    );
116}
117
118/// Build the engine seam callbacks: the runtime coverage sidecar adapter and
119/// the graph-structure telemetry hook.
120fn health_seams<'a>() -> HealthSeams<'a> {
121    HealthSeams {
122        runtime_coverage_analyzer: &runtime_coverage_seam,
123        note_graph_structure: &|module_count, edge_count| {
124            crate::telemetry::note_graph_structure_counts(module_count, edge_count);
125        },
126    }
127}
128
129/// Adapt the engine's runtime coverage seam input to the CLI coverage module,
130/// which owns the closed-source sidecar (license verification, subprocess
131/// spawning, signal handling).
132#[expect(
133    clippy::needless_pass_by_value,
134    reason = "by-value input matches the engine RuntimeCoverageAnalyzer seam signature"
135)]
136fn runtime_coverage_seam(
137    options: &fallow_engine::health::RuntimeCoverageOptions,
138    input: RuntimeCoverageSeamInput<'_>,
139) -> Result<fallow_output::RuntimeCoverageReport, u8> {
140    coverage::analyze(
141        options,
142        &coverage::RuntimeCoverageAnalysisInput {
143            root: input.root,
144            modules: input.modules,
145            analysis_output: input.analysis_output,
146            istanbul_coverage: input.istanbul_coverage,
147            file_paths: input.file_paths,
148            ignore_set: input.ignore_set,
149            changed_files: input.changed_files,
150            ws_roots: input.ws_roots,
151            top: input.top,
152            codeowners_path: input.codeowners_path,
153            quiet: input.quiet,
154            output: input.output,
155        },
156    )
157}
158
159/// Resolve the command-neutral scope inputs the engine needs: changed files,
160/// the diff index, workspace roots, and the grouping resolver.
161fn build_health_scope_inputs<'a>(
162    opts: &HealthOptions<'a>,
163    config: &fallow_config::ResolvedConfig,
164) -> Result<HealthScopeInputs<'a, OwnershipResolver>, ExitCode> {
165    let changed_files = opts
166        .changed_since
167        .and_then(|git_ref| get_changed_files(opts.root, git_ref));
168    let diff_index = health_diff_index(opts);
169    let mut ws_roots = resolve_workspace_scope(
170        opts.root,
171        opts.workspace,
172        opts.changed_workspaces,
173        opts.output,
174    )?;
175    if let Some(scope) = opts.scope.as_ref() {
176        ws_roots.get_or_insert_with(Vec::new).push(scope.clone());
177    }
178    let group_resolver = build_health_group_resolver(opts, config)?;
179    Ok(HealthScopeInputs {
180        changed_files,
181        diff_index,
182        ws_roots,
183        group_resolver,
184    })
185}
186
187/// Translate an engine [`HealthError`] into a CLI exit code at the command
188/// boundary. `Message` is rendered here (the engine no longer prints fatal
189/// errors); `Printed` was already emitted by a lower layer (the runtime-coverage
190/// seam), so its exit code is honored without a second error document.
191fn health_err_to_exit(error: HealthError, output: OutputFormat) -> ExitCode {
192    match error {
193        HealthError::Message { message, exit_code } => emit_error(&message, exit_code, output),
194        HealthError::Printed(code) => ExitCode::from(code),
195    }
196}
197
198/// Load config for a health run, validating coverage-root and churn-file inputs
199/// up front (loud exit 2 on a malformed input).
200pub fn load_health_config(
201    opts: &HealthOptions<'_>,
202) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
203    fallow_engine::health::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
204        .map_err(|e| emit_error(&e, 2, opts.output))?;
205    validate_health_churn_file(opts).map_err(|e| health_err_to_exit(e, opts.output))?;
206    let t = Instant::now();
207    let config = crate::load_config_for_analysis(
208        opts.root,
209        opts.config_path,
210        crate::ConfigLoadOptions {
211            output: opts.output,
212            no_cache: opts.no_cache,
213            threads: opts.threads,
214            production_override: opts
215                .production_override
216                .or_else(|| opts.production.then_some(true)),
217            quiet: opts.quiet,
218            allow_remote_extends: opts.allow_remote_extends,
219        },
220        fallow_config::ProductionAnalysis::Health,
221    )?;
222    let config_ms = t.elapsed().as_secs_f64() * 1000.0;
223    Ok((config, config_ms))
224}
225
226/// Run health analysis using pre-parsed modules from the dead-code pipeline.
227///
228/// Skips file discovery and parsing (saves ~1.9s on 21K-file projects).
229pub fn execute_health_with_shared_parse(
230    opts: &HealthOptions<'_>,
231    shared: HealthSharedParseData,
232) -> Result<HealthResult, ExitCode> {
233    let (config, config_ms) = load_health_config(opts)?;
234    let scope_inputs = build_health_scope_inputs(opts, &config)?;
235    let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
236    let workspaces = shared.workspaces;
237    let seams = health_seams();
238    let result = execute_health_inner(
239        opts,
240        HealthPipelineInputs {
241            config,
242            files: shared.files,
243            modules: shared.modules,
244            config_ms,
245            discover_ms: 0.0,
246            parse_ms: 0.0,
247            parse_cpu_ms: 0.0,
248            shared_parse: true,
249            pre_computed_analysis: shared.analysis_output,
250            dead_code_results: shared.dead_code_results,
251            styling_artifacts: None,
252            pre_computed_duplication: None,
253            workspaces,
254            workspace_diagnostics,
255        },
256        scope_inputs,
257        &seams,
258    )
259    .map_err(|e| health_err_to_exit(e, opts.output))?;
260    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
261    Ok(result)
262}
263
264pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
265    let (config, config_ms) = load_health_config(opts)?;
266    execute_health_with_config(opts, config, config_ms)
267}
268
269pub fn execute_health_with_config(
270    opts: &HealthOptions<'_>,
271    config: fallow_config::ResolvedConfig,
272    config_ms: f64,
273) -> Result<HealthResult, ExitCode> {
274    let seams = health_seams();
275    let result = execute_health_with_config_and_seams(opts, config, config_ms, &seams)?;
276    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
277    Ok(result)
278}
279
280fn execute_health_with_config_and_seams(
281    opts: &HealthOptions<'_>,
282    config: fallow_config::ResolvedConfig,
283    config_ms: f64,
284    seams: &HealthSeams<'_>,
285) -> Result<HealthResult, ExitCode> {
286    let t = Instant::now();
287    let session = fallow_engine::session::AnalysisSession::from_resolved_config(config)
288        .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
289    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
290    let parts = session.parsed_parts_uncached(true);
291    let pre_computed_analysis =
292        fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
293            .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
294            .transpose()
295            .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
296    let config = parts.config;
297    let files = parts.files;
298    let modules = parts.modules;
299    let workspaces = parts.workspaces;
300    let workspace_diagnostics = if pre_computed_analysis.is_some() {
301        session.current_workspace_diagnostics()
302    } else {
303        parts.workspace_diagnostics
304    };
305    let parse_ms = parts.parse_ms;
306    let parse_cpu_ms = parts.parse_cpu_ms;
307
308    let scope_inputs = build_health_scope_inputs(opts, &config)?;
309    execute_health_inner(
310        opts,
311        HealthPipelineInputs {
312            config,
313            files,
314            modules,
315            config_ms,
316            discover_ms,
317            parse_ms,
318            parse_cpu_ms,
319            shared_parse: false,
320            dead_code_results: None,
321            styling_artifacts: None,
322            pre_computed_analysis,
323            pre_computed_duplication: None,
324            workspaces,
325            workspace_diagnostics,
326        },
327        scope_inputs,
328        seams,
329    )
330    .map_err(|e| health_err_to_exit(e, opts.output))
331}
332
333pub fn benchmark_execute_health_with_response(
334    opts: &HealthOptions<'_>,
335    response_bytes: &[u8],
336    request_len: &std::cell::Cell<usize>,
337) -> Result<HealthResult, ExitCode> {
338    let analyzer = |options: &fallow_engine::health::RuntimeCoverageOptions,
339                    input: RuntimeCoverageSeamInput<'_>| {
340        coverage::analyze_with_transport(
341            options,
342            &coverage::RuntimeCoverageAnalysisInput {
343                root: input.root,
344                modules: input.modules,
345                analysis_output: input.analysis_output,
346                istanbul_coverage: input.istanbul_coverage,
347                file_paths: input.file_paths,
348                ignore_set: input.ignore_set,
349                changed_files: input.changed_files,
350                ws_roots: input.ws_roots,
351                top: input.top,
352                codeowners_path: input.codeowners_path,
353                quiet: input.quiet,
354                output: input.output,
355            },
356            |request, _quiet, output| {
357                let (response, len) =
358                    coverage::in_process_response_transport(request, response_bytes, output)?;
359                request_len.set(len);
360                Ok(response)
361            },
362        )
363    };
364    let seams = HealthSeams {
365        runtime_coverage_analyzer: &analyzer,
366        note_graph_structure: &|_module_count, _edge_count| {},
367    };
368    let (config, config_ms) = load_health_config(opts)?;
369    execute_health_with_config_and_seams(opts, config, config_ms, &seams)
370}
371
372pub fn run_health(
373    opts: &HealthOptions<'_>,
374    json_style: crate::json_style::JsonStyle,
375    type_aware: &TypeAwareHealthOptions<'_>,
376) -> ExitCode {
377    let mut completeness_failed = false;
378    let (config, config_ms) = match load_health_config(opts) {
379        Ok(config) => config,
380        Err(code) => return code,
381    };
382    let resolved_type_aware = match resolve_type_aware_health_options(type_aware, &config) {
383        Ok(options) => options,
384        Err(message) => return emit_error(&message, 2, opts.output),
385    };
386    let requested = type_aware.requested || (type_aware.unfiltered && resolved_type_aware.enabled);
387    let mut degraded_meta = None;
388    let semantic = if requested {
389        let enabled = resolved_type_aware.enabled;
390        if !enabled {
391            return emit_error(
392                "--type-coupling requires --type-aware or typeAware.enabled in config",
393                2,
394                opts.output,
395            );
396        }
397        let projects = resolved_type_aware.projects;
398        let require = resolved_type_aware.require;
399        let outcome = match fallow_api::analyze_type_coupling(opts.root, &projects, &[]) {
400            Ok(outcome) => Some(outcome),
401            Err(error) => {
402                match crate::type_aware_degrade::degrade_or_fail(
403                    &crate::type_aware_degrade::DegradeContext {
404                        root: opts.root,
405                        error: &error.to_string(),
406                        failure_label: "Type-aware coupling failed",
407                        require,
408                        quiet: opts.quiet,
409                        output: opts.output,
410                    },
411                ) {
412                    Ok(meta) => degraded_meta = Some(meta),
413                    Err(code) => return code,
414                }
415                None
416            }
417        };
418        completeness_failed = outcome.as_ref().is_some_and(|outcome| {
419            require == fallow_config::TypeAwareRequire::Complete
420                && outcome.report.status != fallow_types::semantic::SemanticCompleteness::Complete
421        });
422        outcome
423    } else {
424        None
425    };
426    let mut execution_opts = opts.clone();
427    if let Some(identity) = semantic
428        .as_ref()
429        .and_then(|outcome| outcome.type_aware.meta.identity.clone())
430    {
431        execution_opts.analysis_identity = identity;
432    }
433    let mut result = match execute_health_with_config(&execution_opts, config, config_ms) {
434        Ok(result) => result,
435        Err(code) => return code,
436    };
437    let required_completeness = result.config.type_aware.require.into();
438    result.type_aware_meta = semantic
439        .map(|outcome| {
440            let mut meta = outcome.type_aware.meta;
441            meta.required_completeness = Some(required_completeness);
442            meta
443        })
444        .or(degraded_meta);
445    if let Some(ref timings) = result.timings {
446        report::print_health_performance(timings, opts.output, json_style);
447    }
448    let code = print_health_result(
449        &result,
450        HealthPrintOptions {
451            quiet: opts.quiet,
452            explain: opts.explain,
453            gates: opts.gates,
454            baseline_path: opts.baseline,
455            summary: opts.summary,
456            summary_heading: true,
457            show_explain_tip: true,
458            type_aware_scope: None,
459            skip_score_and_trend: false,
460            css_requested: opts.css,
461            json_style,
462        },
463    );
464    if code == ExitCode::SUCCESS && completeness_failed {
465        ExitCode::from(1)
466    } else {
467        code
468    }
469}
470
471pub struct ResolvedTypeAwareHealthOptions {
472    pub enabled: bool,
473    pub projects: Vec<std::path::PathBuf>,
474    pub require: fallow_config::TypeAwareRequire,
475}
476
477pub fn resolve_type_aware_health_options(
478    options: &TypeAwareHealthOptions<'_>,
479    config: &fallow_config::ResolvedConfig,
480) -> Result<ResolvedTypeAwareHealthOptions, String> {
481    let env_enabled = std::env::var("FALLOW_TYPE_AWARE")
482        .ok()
483        .map(|value| match value.trim().to_ascii_lowercase().as_str() {
484            "1" | "true" | "yes" | "on" => Ok(true),
485            "0" | "false" | "no" | "off" => Ok(false),
486            _ => Err(
487                "FALLOW_TYPE_AWARE must be one of true, false, 1, 0, yes, no, on, or off"
488                    .to_string(),
489            ),
490        })
491        .transpose()?;
492    let enabled = options
493        .enabled
494        .or(env_enabled)
495        .unwrap_or(config.type_aware.enabled);
496    let projects = if !options.projects.is_empty() {
497        options.projects.to_vec()
498    } else if let Some(value) = std::env::var_os("FALLOW_TYPE_AWARE_PROJECTS") {
499        std::env::split_paths(&value).collect()
500    } else {
501        config
502            .type_aware
503            .projects
504            .iter()
505            .map(std::path::PathBuf::from)
506            .collect()
507    };
508    let require = if let Some(require) = options.require {
509        require
510    } else if let Ok(value) = std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
511        match value.trim().to_ascii_lowercase().as_str() {
512            "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
513            "complete" => fallow_config::TypeAwareRequire::Complete,
514            _ => {
515                return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete".to_string());
516            }
517        }
518    } else {
519        config.type_aware.require
520    };
521    Ok(ResolvedTypeAwareHealthOptions {
522        enabled,
523        projects,
524        require,
525    })
526}
527
528/// Result of executing health analysis without printing.
529pub type HealthResult =
530    fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
531
532/// Print health results and return appropriate exit code.
533///
534/// When called from combined mode (`fallow --score` / `fallow --trend`),
535/// `skip_score_and_trend` MUST be `true`: the orientation header already
536/// renders both blocks and rendering them a second time here would duplicate
537/// the lines. Standalone `fallow health` invocations pass `false`.
538///
539/// Exit-code gating (when `report_only` is `false`): the score gate
540/// (`--min-score`), the findings gate (`--min-severity`, or any finding when
541/// no gate flag is set), the runtime-coverage gate, the opt-in stale-baseline
542/// gate (`--fail-on-stale-baseline`) and the coverage-gap gate are
543/// OR-combined. `report_only` short-circuits all of them to
544/// `ExitCode::SUCCESS` after rendering. Combined and audit callers pass
545/// `report_only: false` (they own their own gate semantics).
546///
547/// Callers that pass `min_score: Some(_)` must ensure
548/// `result.report.health_score` is `Some` (the CLI guarantees this because
549/// `--min-score` implies `--score`). If the score is missing the score gate
550/// cannot evaluate, so a direct API caller that requests a score gate without
551/// computing the score would get a permissive `ExitCode::SUCCESS`.
552#[derive(Clone, Copy)]
553pub struct HealthPrintOptions<'a> {
554    pub quiet: bool,
555    pub explain: bool,
556    pub gates: HealthGateOptions,
557    /// Loaded `--baseline` path, so the stale-baseline gate can name the file
558    /// to re-save. `None` when no baseline was loaded, which makes the gate
559    /// inert.
560    pub baseline_path: Option<&'a std::path::Path>,
561    pub summary: bool,
562    pub summary_heading: bool,
563    pub show_explain_tip: bool,
564    pub type_aware_scope: Option<&'static str>,
565    pub skip_score_and_trend: bool,
566    /// Whether `--css` was requested. Forwarded to the human renderer so an empty
567    /// CSS result (no import-reachable stylesheet) is explained rather than
568    /// silently omitted. Defaults `false` for callers that do not request CSS.
569    pub css_requested: bool,
570    pub json_style: crate::json_style::JsonStyle,
571}
572
573pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions<'_>) -> ExitCode {
574    let ctx = health_report_context(result, options);
575    let report_code = report::print_health_report(
576        &result.report,
577        result.grouping.as_ref(),
578        result.group_resolver.as_ref(),
579        &ctx,
580        result.config.output,
581    );
582    if report_code != ExitCode::SUCCESS {
583        return report_code;
584    }
585
586    if options.gates.report_only {
587        note_stale_baseline_gate_stood_down(result, options);
588        return ExitCode::SUCCESS;
589    }
590
591    if health_exit_gate_failed(result, options) {
592        return ExitCode::from(1);
593    }
594    if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
595        return ExitCode::from(1);
596    }
597    maybe_print_score_gate_note(result, options);
598
599    ExitCode::SUCCESS
600}
601
602fn health_report_context<'a>(
603    result: &'a HealthResult,
604    options: HealthPrintOptions<'a>,
605) -> report::ReportContext<'a> {
606    report::ReportContext {
607        root: &result.config.root,
608        rules: &result.config.rules,
609        workspace_diagnostics: &result.workspace_diagnostics,
610        elapsed: result.elapsed,
611        quiet: options.quiet,
612        explain: options.explain,
613        type_aware: result.type_aware_meta.as_ref(),
614        type_aware_scope: options.type_aware_scope,
615        group_by: None,
616        top: None,
617        summary: options.summary,
618        summary_heading: options.summary_heading,
619        show_explain_tip: options.show_explain_tip,
620        baseline_matched: None,
621        baseline_staleness: None,
622        gate_outcomes: health_gate_outcomes(result, options),
623        config_fixable: false,
624        skip_score_and_trend: options.skip_score_and_trend,
625        css_requested: options.css_requested,
626        json_style: options.json_style,
627        include_fragments: true,
628    }
629}
630
631/// The gates a health run armed, for the envelope's `gate_outcomes`.
632///
633/// Every entry reads the same predicate the exit path reads, so the published
634/// verdict and the process status cannot disagree. `--report-only` returns
635/// `ExitCode::SUCCESS` before any gate is consulted, so it clamps `enforced` to
636/// false on every entry while leaving each verdict in place; that is the case a
637/// boolean-only shape could not express, and the stale-baseline entry is
638/// clamped with the rest rather than reporting the flag it was armed with.
639///
640/// A gate armed by an explicit flag or by config always produces an entry.
641/// `health-findings` is the exception: it fails a plain `fallow health` run on
642/// any finding, which is the command's default rather than a gate a repository
643/// asked for, so it appears only once something else armed. That keeps an
644/// unarmed run byte-identical to one produced before this object existed, and
645/// it is why an absent object must be read as "no gate was asked for" rather
646/// than "nothing failed". Once the object exists the default rule is always in
647/// it, so the object can explain the exit code it sits beside.
648fn health_gate_outcomes(
649    result: &HealthResult,
650    options: HealthPrintOptions<'_>,
651) -> Option<fallow_output::GateOutcomes> {
652    use fallow_output::{GateName, GateOutcome, GateStatus};
653
654    let enforced = !options.gates.report_only;
655    let mut gates = fallow_output::GateOutcomes::new();
656
657    if let Some(threshold) = options.gates.min_score {
658        // `--min-score` implies `--score`, so a missing score means the caller
659        // is a programmatic one that requested the gate without computing what
660        // it compares. Report the stand-down rather than nothing, or "armed"
661        // and "not armed" read identically.
662        gates.insert(
663            GateName::HealthMinScore,
664            result.report.health_score.as_ref().map_or_else(
665                || GateOutcome::new(GateStatus::Skipped, false),
666                |score| {
667                    GateOutcome::measured(
668                        crate::gates::status_of(score.score < threshold),
669                        enforced,
670                        score.score,
671                        threshold,
672                    )
673                },
674            ),
675        );
676    }
677
678    if let Some(min_sev) = options.gates.min_severity {
679        let reached = result
680            .report
681            .findings
682            .iter()
683            .filter(|f| f.severity >= min_sev)
684            .count();
685        gates.insert(
686            GateName::HealthMinSeverity,
687            GateOutcome::counted(
688                crate::gates::status_of(reached > 0),
689                enforced,
690                #[expect(
691                    clippy::cast_precision_loss,
692                    reason = "a finding count never approaches the f64 integer limit"
693                )]
694                {
695                    reached as f64
696                },
697                severity_floor_label(min_sev),
698            ),
699        );
700    }
701
702    if result.should_fail_on_coverage_gaps {
703        gates.insert(
704            GateName::HealthCoverageGaps,
705            GateOutcome::new(
706                crate::gates::status_of(result.coverage_gaps_has_findings),
707                enforced,
708            ),
709        );
710    }
711
712    // Armed by `--runtime-coverage`, so it belongs with the flag-armed gates
713    // rather than behind the default-rule guard below: without this a run whose
714    // only gate is runtime coverage exits 1 and publishes nothing.
715    if result.report.runtime_coverage.is_some() {
716        gates.insert(
717            GateName::HealthRuntimeCoverage,
718            GateOutcome::new(
719                crate::gates::status_of(has_failing_runtime_coverage(result)),
720                enforced,
721            ),
722        );
723    }
724
725    gates.insert_if(
726        GateName::StaleBaseline,
727        crate::gates::stale_baseline_outcome(
728            result.report.summary.baseline_staleness.as_ref(),
729            options.gates.fail_on_stale_baseline && enforced,
730        ),
731    );
732
733    if gates.is_empty() {
734        return None;
735    }
736
737    // The default findings rule, reached only once a gate was armed. With
738    // `--min-severity` the findings gate IS the severity gate, already
739    // recorded above under its own name.
740    if options.gates.min_severity.is_none() {
741        gates.insert(
742            GateName::HealthFindings,
743            if options.gates.min_score.is_some() {
744                // `--min-score` alone turns the findings branch off, which is
745                // what "complexity findings become informational" means.
746                GateOutcome::new(GateStatus::Skipped, false)
747            } else {
748                GateOutcome::new(
749                    crate::gates::status_of(!result.report.findings.is_empty()),
750                    enforced,
751                )
752            },
753        );
754    }
755
756    gates.into_option()
757}
758
759/// The wire spelling of a severity floor, for `threshold_label`.
760const fn severity_floor_label(severity: fallow_output::FindingSeverity) -> &'static str {
761    match severity {
762        fallow_output::FindingSeverity::Moderate => "moderate",
763        fallow_output::FindingSeverity::High => "high",
764        fallow_output::FindingSeverity::Critical => "critical",
765    }
766}
767
768/// The OR of every health exit gate, with each one evaluated before the verdict
769/// is combined so that none of them can swallow another's stderr line.
770///
771/// The baseline gate is why this is not a short-circuiting chain: the score and
772/// findings gates have their condition printed in the report, a stale baseline
773/// has it nowhere, so a run that already fails the findings gate would exit 1
774/// with nothing about the baseline the user explicitly gated on.
775fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
776    let score = score_gate_failed(result, options);
777    let findings = findings_gate_failed(result, options);
778    let runtime_coverage = has_failing_runtime_coverage(result);
779    let stale_baseline = stale_baseline_gate_failed(result, options);
780    score || findings || runtime_coverage || stale_baseline
781}
782
783/// Say that `--report-only` suppressed the gate, so a job that passes both
784/// flags learns its baseline was never judged instead of going green forever.
785///
786/// `--report-only` is an explicit request never to fail the run, so the gate
787/// obeys it rather than overriding it; it just does not obey it in silence.
788fn note_stale_baseline_gate_stood_down(result: &HealthResult, options: HealthPrintOptions<'_>) {
789    let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
790        return;
791    };
792    if staleness.baseline_entries == 0 {
793        return;
794    }
795    crate::baseline_gate::note_stood_down(
796        options.baseline_path,
797        options.gates.fail_on_stale_baseline,
798        "--report-only never fails a run",
799    );
800}
801
802/// The opt-in `--fail-on-stale-baseline` gate. Reads the staleness the engine
803/// already put in the report, so no extra plumbing crosses the engine boundary.
804fn stale_baseline_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
805    let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
806        return false;
807    };
808    crate::baseline_gate::gate_failed_from_counts(
809        staleness.baseline_entries,
810        staleness.matched_entries,
811        staleness.change_scoped,
812        options.baseline_path,
813        options.gates.fail_on_stale_baseline,
814        crate::baseline_gate::HEALTH_NOUN,
815    )
816}
817
818fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
819    let Some(threshold) = options.gates.min_score else {
820        return false;
821    };
822    let Some(ref hs) = result.report.health_score else {
823        return false;
824    };
825    if hs.score >= threshold {
826        return false;
827    }
828
829    if !options.quiet {
830        eprintln!(
831            "Health score {:.1} ({}) is below minimum threshold {:.0}",
832            hs.score, hs.grade, threshold
833        );
834    }
835    true
836}
837
838fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
839    if let Some(min_sev) = options.gates.min_severity {
840        result.report.findings.iter().any(|f| f.severity >= min_sev)
841    } else if options.gates.min_score.is_none() {
842        !result.report.findings.is_empty()
843    } else {
844        false
845    }
846}
847
848fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
849    result
850        .report
851        .runtime_coverage
852        .as_ref()
853        .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
854}
855
856fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
857    matches!(
858        finding.verdict,
859        fallow_output::RuntimeCoverageVerdict::SafeToDelete
860            | fallow_output::RuntimeCoverageVerdict::ReviewRequired
861            | fallow_output::RuntimeCoverageVerdict::LowTraffic
862    )
863}
864
865fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions<'_>) {
866    if options.gates.min_score.is_none()
867        || options.gates.min_severity.is_some()
868        || options.quiet
869        || result.report.findings.is_empty()
870        || !matches!(result.config.output, OutputFormat::Human)
871    {
872        return;
873    }
874
875    {
876        eprintln!(
877            "{}",
878            "Findings above are informational: --min-score gates on the score, not on findings."
879                .dimmed()
880        );
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use fallow_config::{FallowConfig, OutputFormat};
888    use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
889    use std::path::PathBuf;
890    use std::time::Duration;
891
892    fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
893        ComplexityViolation {
894            path: PathBuf::from("/project/src/a.ts"),
895            name: name.to_string(),
896            line: 1,
897            col: 0,
898            cyclomatic: match exceeded {
899                ExceededThreshold::Cyclomatic
900                | ExceededThreshold::Both
901                | ExceededThreshold::CyclomaticCrap
902                | ExceededThreshold::All => 25,
903                _ => 8,
904            },
905            cognitive: match exceeded {
906                ExceededThreshold::Cognitive
907                | ExceededThreshold::Both
908                | ExceededThreshold::CognitiveCrap
909                | ExceededThreshold::All => 20,
910                _ => 5,
911            },
912            line_count: 10,
913            param_count: 0,
914            react_hook_count: 0,
915            react_jsx_max_depth: 0,
916            react_prop_count: 0,
917            react_hook_profile: None,
918            exceeded,
919            severity: FindingSeverity::Moderate,
920            crap: exceeded.includes_crap().then_some(30.0),
921            coverage_pct: None,
922            coverage_tier: None,
923            coverage_source: None,
924            inherited_from: None,
925            component_rollup: None,
926            contributions: Vec::new(),
927            effective_thresholds: None,
928            threshold_source: None,
929        }
930    }
931
932    fn test_resolved_config() -> fallow_config::ResolvedConfig {
933        FallowConfig::default().resolve(
934            PathBuf::from("/project"),
935            OutputFormat::Json,
936            1,
937            true,
938            true,
939            None,
940        )
941    }
942
943    fn fx_summary(
944        tracked: usize,
945        hit: usize,
946        unhit: usize,
947        untracked: usize,
948    ) -> fallow_output::RuntimeCoverageSummary {
949        #[expect(
950            clippy::cast_precision_loss,
951            reason = "test fixture totals are tiny, f64 precision is fine"
952        )]
953        let coverage_percent = if tracked == 0 {
954            0.0
955        } else {
956            (hit as f64 / tracked as f64) * 100.0
957        };
958        fallow_output::RuntimeCoverageSummary {
959            data_source: fallow_output::RuntimeCoverageDataSource::Local,
960            last_received_at: None,
961            functions_tracked: tracked,
962            functions_hit: hit,
963            functions_unhit: unhit,
964            functions_untracked: untracked,
965            coverage_percent,
966            trace_count: 512,
967            period_days: 7,
968            deployments_seen: 2,
969            capture_quality: None,
970        }
971    }
972
973    fn fx_evidence(
974        static_status: &str,
975        test_coverage: &str,
976        v8_tracking: &str,
977    ) -> fallow_output::RuntimeCoverageEvidence {
978        fallow_output::RuntimeCoverageEvidence {
979            static_status: static_status.to_owned(),
980            test_coverage: test_coverage.to_owned(),
981            test_only_reference: None,
982            v8_tracking: v8_tracking.to_owned(),
983            untracked_reason: None,
984            observation_days: 7,
985            deployments_observed: 2,
986        }
987    }
988
989    fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
990        fallow_output::HealthScore {
991            formula_version: 2,
992            score,
993            grade,
994            penalties: fallow_output::HealthScorePenalties {
995                dead_files: None,
996                dead_exports: None,
997                complexity: 0.0,
998                p90_complexity: 0.0,
999                maintainability: None,
1000                hotspots: None,
1001                unused_deps: None,
1002                circular_deps: None,
1003                unit_size: None,
1004                coupling: None,
1005                duplication: None,
1006                prop_drilling: None,
1007            },
1008        }
1009    }
1010
1011    fn fx_gate_result(
1012        findings: Vec<fallow_output::HealthFinding>,
1013        score: Option<fallow_output::HealthScore>,
1014    ) -> HealthResult {
1015        HealthResult {
1016            branching_by_file: fallow_engine::health::BranchingByFile::default(),
1017            report: fallow_output::HealthReport {
1018                findings,
1019                health_score: score,
1020                ..fallow_output::HealthReport::default()
1021            },
1022            grouping: None,
1023            group_resolver: None,
1024            config: test_resolved_config(),
1025            workspace_diagnostics: Vec::new(),
1026            elapsed: Duration::default(),
1027            timings: None,
1028            type_aware_meta: None,
1029            coverage_gaps_has_findings: false,
1030            should_fail_on_coverage_gaps: false,
1031        }
1032    }
1033
1034    fn moderate_finding() -> fallow_output::HealthFinding {
1035        make_finding("moderate", ExceededThreshold::Cyclomatic).into()
1036    }
1037
1038    fn critical_finding() -> fallow_output::HealthFinding {
1039        let mut v = make_finding("critical", ExceededThreshold::All);
1040        v.severity = FindingSeverity::Critical;
1041        v.into()
1042    }
1043
1044    /// Helper: run the gate with the given flags, quiet, no report-only.
1045    fn gate_exit(
1046        result: &HealthResult,
1047        min_score: Option<f64>,
1048        min_severity: Option<FindingSeverity>,
1049        report_only: bool,
1050    ) -> ExitCode {
1051        print_health_result(
1052            result,
1053            HealthPrintOptions {
1054                quiet: true,
1055                explain: false,
1056                gates: HealthGateOptions {
1057                    min_score,
1058                    min_severity,
1059                    report_only,
1060                    fail_on_stale_baseline: false,
1061                },
1062                baseline_path: None,
1063                summary: false,
1064                summary_heading: true,
1065                show_explain_tip: true,
1066                type_aware_scope: None,
1067                skip_score_and_trend: false,
1068                css_requested: false,
1069                json_style: crate::json_style::JsonStyle::Compact,
1070            },
1071        )
1072    }
1073
1074    #[test]
1075    fn plain_health_with_findings_fails() {
1076        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1077        assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1078    }
1079
1080    #[test]
1081    fn plain_health_with_no_findings_succeeds() {
1082        let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1083        assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
1084    }
1085
1086    #[test]
1087    fn min_score_zero_never_fails_even_with_findings() {
1088        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1089        assert_eq!(
1090            gate_exit(&result, Some(0.0), None, false),
1091            ExitCode::SUCCESS
1092        );
1093    }
1094
1095    #[test]
1096    fn min_score_passing_demotes_findings_to_informational() {
1097        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1098        assert_eq!(
1099            gate_exit(&result, Some(80.0), None, false),
1100            ExitCode::SUCCESS
1101        );
1102    }
1103
1104    #[test]
1105    fn min_score_below_threshold_fails() {
1106        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1107        assert_eq!(
1108            gate_exit(&result, Some(80.0), None, false),
1109            ExitCode::from(1)
1110        );
1111    }
1112
1113    #[test]
1114    fn min_severity_gates_on_severity_independent_of_min_score() {
1115        let only_moderate =
1116            fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1117        assert_eq!(
1118            gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
1119            ExitCode::SUCCESS,
1120        );
1121        let with_critical = fx_gate_result(
1122            vec![moderate_finding(), critical_finding()],
1123            Some(fx_health_score(87.5, "A")),
1124        );
1125        assert_eq!(
1126            gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
1127            ExitCode::from(1),
1128        );
1129    }
1130
1131    #[test]
1132    fn min_score_and_min_severity_compose_as_or() {
1133        let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1134        assert_eq!(
1135            gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
1136            ExitCode::SUCCESS,
1137        );
1138        let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1139        assert_eq!(
1140            gate_exit(
1141                &low_score,
1142                Some(80.0),
1143                Some(FindingSeverity::Critical),
1144                false
1145            ),
1146            ExitCode::from(1),
1147        );
1148        let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
1149        assert_eq!(
1150            gate_exit(
1151                &critical,
1152                Some(80.0),
1153                Some(FindingSeverity::Critical),
1154                false
1155            ),
1156            ExitCode::from(1),
1157        );
1158    }
1159
1160    #[test]
1161    fn report_only_never_fails_on_findings_or_low_score() {
1162        let result = fx_gate_result(
1163            vec![moderate_finding(), critical_finding()],
1164            Some(fx_health_score(10.0, "F")),
1165        );
1166        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1167    }
1168
1169    #[test]
1170    fn runtime_coverage_gate_independent_of_min_score() {
1171        let result = fx_low_traffic_runtime_result();
1172        assert_eq!(
1173            gate_exit(&result, Some(0.0), None, false),
1174            ExitCode::from(1)
1175        );
1176        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1177    }
1178
1179    fn fx_low_traffic_runtime_result() -> HealthResult {
1180        HealthResult {
1181            branching_by_file: fallow_engine::health::BranchingByFile::default(),
1182            report: fallow_output::HealthReport {
1183                runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
1184                    schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
1185                    verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
1186                    signals: Vec::new(),
1187                    summary: fx_summary(1, 0, 1, 0),
1188                    findings: vec![fallow_output::RuntimeCoverageFinding {
1189                        id: "fallow:prod:lowtraffic".to_owned(),
1190                        stable_id: None,
1191                        path: PathBuf::from("/project/src/cold.ts"),
1192                        function: "coldPath".to_owned(),
1193                        line: 14,
1194                        verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
1195                        invocations: Some(1),
1196                        confidence: fallow_output::RuntimeCoverageConfidence::Low,
1197                        evidence: fx_evidence("used", "not_covered", "tracked"),
1198                        actions: vec![],
1199                        source_hash: None,
1200                        discriminators: None,
1201                    }],
1202                    hot_paths: vec![],
1203                    blast_radius: vec![],
1204                    importance: vec![],
1205                    watermark: None,
1206                    warnings: vec![],
1207                    actionable: true,
1208                    actionability_reason: None,
1209                    actionability_verdict: None,
1210                    provenance: fallow_output::RuntimeCoverageProvenance::default(),
1211                }),
1212                ..fallow_output::HealthReport::default()
1213            },
1214            grouping: None,
1215            group_resolver: None,
1216            config: test_resolved_config(),
1217            workspace_diagnostics: Vec::new(),
1218            elapsed: Duration::default(),
1219            timings: None,
1220            type_aware_meta: None,
1221            coverage_gaps_has_findings: false,
1222            should_fail_on_coverage_gaps: false,
1223        }
1224    }
1225
1226    #[test]
1227    fn print_health_result_fails_on_low_traffic_runtime_coverage() {
1228        let result = fx_low_traffic_runtime_result();
1229
1230        assert_eq!(
1231            print_health_result(
1232                &result,
1233                HealthPrintOptions {
1234                    quiet: true,
1235                    explain: false,
1236                    gates: HealthGateOptions::default(),
1237                    baseline_path: None,
1238                    summary: false,
1239                    summary_heading: true,
1240                    show_explain_tip: true,
1241                    type_aware_scope: None,
1242                    skip_score_and_trend: false,
1243                    css_requested: false,
1244                    json_style: crate::json_style::JsonStyle::Compact,
1245                },
1246            ),
1247            ExitCode::from(1),
1248        );
1249    }
1250}