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
55impl HealthGroupResolver for OwnershipResolver {
56    fn mode_label(&self) -> &'static str {
57        OwnershipResolver::mode_label(self)
58    }
59
60    fn resolve_with_rule(&self, rel_path: &std::path::Path) -> (String, Option<String>) {
61        OwnershipResolver::resolve_with_rule(self, rel_path)
62    }
63
64    fn section_owners_of(&self, rel_path: &std::path::Path) -> Option<&[String]> {
65        OwnershipResolver::section_owners_of(self, rel_path)
66    }
67}
68
69/// Resolve the diff index for a health run: an explicit `--diff-file` index
70/// wins, otherwise the process-shared diff cache when the caller opted in.
71fn health_diff_index<'a>(opts: &HealthOptions<'a>) -> Option<&'a fallow_output::DiffIndex> {
72    match opts.diff_index {
73        Some(index) => Some(index),
74        None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(),
75        None => None,
76    }
77}
78
79/// Build the CODEOWNERS / package-backed grouping resolver for `--group-by`.
80fn build_health_group_resolver(
81    opts: &HealthOptions<'_>,
82    config: &fallow_config::ResolvedConfig,
83) -> Result<Option<OwnershipResolver>, ExitCode> {
84    crate::runtime_support::build_ownership_resolver_for_mode(
85        opts.group_by,
86        opts.root,
87        config.codeowners.as_deref(),
88        opts.output,
89    )
90}
91
92/// Record health telemetry from the finished report. Mirrors the per-analysis
93/// telemetry the other commands record; lives in the CLI because the telemetry
94/// sinks are process-global CLI state.
95fn record_health_telemetry(report: &fallow_output::HealthReport, coverage_gaps_has_findings: bool) {
96    if coverage_gaps_has_findings && report.findings.is_empty() {
97        crate::telemetry::note_findings_present(true);
98    } else {
99        crate::telemetry::note_result_count(report.findings.len());
100    }
101    crate::telemetry::note_analysis_scale(
102        Some(report.summary.files_analyzed),
103        Some(report.summary.functions_analyzed),
104    );
105}
106
107/// Build the engine seam callbacks: the runtime coverage sidecar adapter and
108/// the graph-structure telemetry hook.
109fn health_seams<'a>() -> HealthSeams<'a> {
110    HealthSeams {
111        runtime_coverage_analyzer: &runtime_coverage_seam,
112        note_graph_structure: &|module_count, edge_count| {
113            crate::telemetry::note_graph_structure_counts(module_count, edge_count);
114        },
115    }
116}
117
118/// Adapt the engine's runtime coverage seam input to the CLI coverage module,
119/// which owns the closed-source sidecar (license verification, subprocess
120/// spawning, signal handling).
121#[expect(
122    clippy::needless_pass_by_value,
123    reason = "by-value input matches the engine RuntimeCoverageAnalyzer seam signature"
124)]
125fn runtime_coverage_seam(
126    options: &fallow_engine::health::RuntimeCoverageOptions,
127    input: RuntimeCoverageSeamInput<'_>,
128) -> Result<fallow_output::RuntimeCoverageReport, u8> {
129    coverage::analyze(
130        options,
131        &coverage::RuntimeCoverageAnalysisInput {
132            root: input.root,
133            modules: input.modules,
134            analysis_output: input.analysis_output,
135            istanbul_coverage: input.istanbul_coverage,
136            file_paths: input.file_paths,
137            ignore_set: input.ignore_set,
138            changed_files: input.changed_files,
139            ws_roots: input.ws_roots,
140            top: input.top,
141            codeowners_path: input.codeowners_path,
142            quiet: input.quiet,
143            output: input.output,
144        },
145    )
146}
147
148/// Resolve the command-neutral scope inputs the engine needs: changed files,
149/// the diff index, workspace roots, and the grouping resolver.
150fn build_health_scope_inputs<'a>(
151    opts: &HealthOptions<'a>,
152    config: &fallow_config::ResolvedConfig,
153) -> Result<HealthScopeInputs<'a, OwnershipResolver>, ExitCode> {
154    let changed_files = opts
155        .changed_since
156        .and_then(|git_ref| get_changed_files(opts.root, git_ref));
157    let diff_index = health_diff_index(opts);
158    let ws_roots = resolve_workspace_scope(
159        opts.root,
160        opts.workspace,
161        opts.changed_workspaces,
162        opts.output,
163    )?;
164    let group_resolver = build_health_group_resolver(opts, config)?;
165    Ok(HealthScopeInputs {
166        changed_files,
167        diff_index,
168        ws_roots,
169        group_resolver,
170    })
171}
172
173/// Translate an engine [`HealthError`] into a CLI exit code at the command
174/// boundary. `Message` is rendered here (the engine no longer prints fatal
175/// errors); `Printed` was already emitted by a lower layer (the runtime-coverage
176/// seam), so its exit code is honored without a second error document.
177fn health_err_to_exit(error: HealthError, output: OutputFormat) -> ExitCode {
178    match error {
179        HealthError::Message { message, exit_code } => emit_error(&message, exit_code, output),
180        HealthError::Printed(code) => ExitCode::from(code),
181    }
182}
183
184/// Load config for a health run, validating coverage-root and churn-file inputs
185/// up front (loud exit 2 on a malformed input).
186fn load_health_config(
187    opts: &HealthOptions<'_>,
188) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
189    fallow_engine::health::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
190        .map_err(|e| emit_error(&e, 2, opts.output))?;
191    validate_health_churn_file(opts).map_err(|e| health_err_to_exit(e, opts.output))?;
192    let t = Instant::now();
193    let config = crate::load_config_for_analysis(
194        opts.root,
195        opts.config_path,
196        crate::ConfigLoadOptions {
197            output: opts.output,
198            no_cache: opts.no_cache,
199            threads: opts.threads,
200            production_override: opts
201                .production_override
202                .or_else(|| opts.production.then_some(true)),
203            quiet: opts.quiet,
204            allow_remote_extends: opts.allow_remote_extends,
205        },
206        fallow_config::ProductionAnalysis::Health,
207    )?;
208    let config_ms = t.elapsed().as_secs_f64() * 1000.0;
209    Ok((config, config_ms))
210}
211
212/// Run health analysis using pre-parsed modules from the dead-code pipeline.
213///
214/// Skips file discovery and parsing (saves ~1.9s on 21K-file projects).
215pub fn execute_health_with_shared_parse(
216    opts: &HealthOptions<'_>,
217    shared: HealthSharedParseData,
218) -> Result<HealthResult, ExitCode> {
219    let (config, config_ms) = load_health_config(opts)?;
220    let scope_inputs = build_health_scope_inputs(opts, &config)?;
221    let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
222    let workspaces = shared.workspaces;
223    let seams = health_seams();
224    let result = execute_health_inner(
225        opts,
226        HealthPipelineInputs {
227            config,
228            files: shared.files,
229            modules: shared.modules,
230            config_ms,
231            discover_ms: 0.0,
232            parse_ms: 0.0,
233            parse_cpu_ms: 0.0,
234            shared_parse: true,
235            pre_computed_analysis: shared.analysis_output,
236            dead_code_results: shared.dead_code_results,
237            styling_artifacts: None,
238            pre_computed_duplication: None,
239            workspaces,
240            workspace_diagnostics,
241        },
242        scope_inputs,
243        &seams,
244    )
245    .map_err(|e| health_err_to_exit(e, opts.output))?;
246    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
247    Ok(result)
248}
249
250pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
251    let (config, config_ms) = load_health_config(opts)?;
252
253    let t = Instant::now();
254    let session = fallow_engine::session::AnalysisSession::from_resolved_config(config);
255    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
256    let parts = session.parsed_parts_uncached(true);
257    let pre_computed_analysis =
258        fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
259            .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
260            .transpose()
261            .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
262    let config = parts.config;
263    let files = parts.files;
264    let modules = parts.modules;
265    let workspaces = parts.workspaces;
266    let workspace_diagnostics = parts.workspace_diagnostics;
267    let parse_ms = parts.parse_ms;
268    let parse_cpu_ms = parts.parse_cpu_ms;
269
270    let scope_inputs = build_health_scope_inputs(opts, &config)?;
271    let seams = health_seams();
272    let result = execute_health_inner(
273        opts,
274        HealthPipelineInputs {
275            config,
276            files,
277            modules,
278            config_ms,
279            discover_ms,
280            parse_ms,
281            parse_cpu_ms,
282            shared_parse: false,
283            dead_code_results: None,
284            styling_artifacts: None,
285            pre_computed_analysis,
286            pre_computed_duplication: None,
287            workspaces,
288            workspace_diagnostics,
289        },
290        scope_inputs,
291        &seams,
292    )
293    .map_err(|e| health_err_to_exit(e, opts.output))?;
294    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
295    Ok(result)
296}
297
298pub fn run_health(opts: &HealthOptions<'_>) -> ExitCode {
299    let result = match execute_health(opts) {
300        Ok(r) => r,
301        Err(code) => return code,
302    };
303    if let Some(ref timings) = result.timings {
304        report::print_health_performance(timings, opts.output);
305    }
306    print_health_result(
307        &result,
308        HealthPrintOptions {
309            quiet: opts.quiet,
310            explain: opts.explain,
311            gates: opts.gates,
312            summary: opts.summary,
313            summary_heading: true,
314            show_explain_tip: true,
315            skip_score_and_trend: false,
316            css_requested: opts.css,
317        },
318    )
319}
320
321/// Result of executing health analysis without printing.
322pub type HealthResult =
323    fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
324
325/// Print health results and return appropriate exit code.
326///
327/// When called from combined mode (`fallow --score` / `fallow --trend`),
328/// `skip_score_and_trend` MUST be `true`: the orientation header already
329/// renders both blocks and rendering them a second time here would duplicate
330/// the lines. Standalone `fallow health` invocations pass `false`.
331///
332/// Exit-code gating (when `report_only` is `false`): the score gate
333/// (`--min-score`), the findings gate (`--min-severity`, or any finding when
334/// no gate flag is set), the runtime-coverage gate, and the coverage-gap gate
335/// are OR-combined. `report_only` short-circuits all of them to
336/// `ExitCode::SUCCESS` after rendering. Combined and audit callers pass
337/// `report_only: false` (they own their own gate semantics).
338///
339/// Callers that pass `min_score: Some(_)` must ensure
340/// `result.report.health_score` is `Some` (the CLI guarantees this because
341/// `--min-score` implies `--score`). If the score is missing the score gate
342/// cannot evaluate, so a direct API caller that requests a score gate without
343/// computing the score would get a permissive `ExitCode::SUCCESS`.
344#[derive(Clone, Copy)]
345pub struct HealthPrintOptions {
346    pub quiet: bool,
347    pub explain: bool,
348    pub gates: HealthGateOptions,
349    pub summary: bool,
350    pub summary_heading: bool,
351    pub show_explain_tip: bool,
352    pub skip_score_and_trend: bool,
353    /// Whether `--css` was requested. Forwarded to the human renderer so an empty
354    /// CSS result (no import-reachable stylesheet) is explained rather than
355    /// silently omitted. Defaults `false` for callers that do not request CSS.
356    pub css_requested: bool,
357}
358
359pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions) -> ExitCode {
360    let ctx = health_report_context(result, options);
361    let report_code = report::print_health_report(
362        &result.report,
363        result.grouping.as_ref(),
364        result.group_resolver.as_ref(),
365        &ctx,
366        result.config.output,
367    );
368    if report_code != ExitCode::SUCCESS {
369        return report_code;
370    }
371
372    if options.gates.report_only {
373        return ExitCode::SUCCESS;
374    }
375
376    if health_exit_gate_failed(result, options) {
377        return ExitCode::from(1);
378    }
379    if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
380        return ExitCode::from(1);
381    }
382    maybe_print_score_gate_note(result, options);
383
384    ExitCode::SUCCESS
385}
386
387fn health_report_context(
388    result: &HealthResult,
389    options: HealthPrintOptions,
390) -> report::ReportContext<'_> {
391    report::ReportContext {
392        root: &result.config.root,
393        rules: &result.config.rules,
394        elapsed: result.elapsed,
395        quiet: options.quiet,
396        explain: options.explain,
397        group_by: None,
398        top: None,
399        summary: options.summary,
400        summary_heading: options.summary_heading,
401        show_explain_tip: options.show_explain_tip,
402        baseline_matched: None,
403        config_fixable: false,
404        skip_score_and_trend: options.skip_score_and_trend,
405        css_requested: options.css_requested,
406    }
407}
408
409fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
410    score_gate_failed(result, options)
411        || findings_gate_failed(result, options)
412        || has_failing_runtime_coverage(result)
413}
414
415fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
416    let Some(threshold) = options.gates.min_score else {
417        return false;
418    };
419    let Some(ref hs) = result.report.health_score else {
420        return false;
421    };
422    if hs.score >= threshold {
423        return false;
424    }
425
426    if !options.quiet {
427        eprintln!(
428            "Health score {:.1} ({}) is below minimum threshold {:.0}",
429            hs.score, hs.grade, threshold
430        );
431    }
432    true
433}
434
435fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
436    if let Some(min_sev) = options.gates.min_severity {
437        result.report.findings.iter().any(|f| f.severity >= min_sev)
438    } else if options.gates.min_score.is_none() {
439        !result.report.findings.is_empty()
440    } else {
441        false
442    }
443}
444
445fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
446    result
447        .report
448        .runtime_coverage
449        .as_ref()
450        .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
451}
452
453fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
454    matches!(
455        finding.verdict,
456        fallow_output::RuntimeCoverageVerdict::SafeToDelete
457            | fallow_output::RuntimeCoverageVerdict::ReviewRequired
458            | fallow_output::RuntimeCoverageVerdict::LowTraffic
459    )
460}
461
462fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions) {
463    if options.gates.min_score.is_none()
464        || options.gates.min_severity.is_some()
465        || options.quiet
466        || result.report.findings.is_empty()
467        || !matches!(result.config.output, OutputFormat::Human)
468    {
469        return;
470    }
471
472    {
473        eprintln!(
474            "{}",
475            "Findings above are informational: --min-score gates on the score, not on findings."
476                .dimmed()
477        );
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use fallow_config::{FallowConfig, OutputFormat};
485    use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
486    use std::path::PathBuf;
487    use std::time::Duration;
488
489    fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
490        ComplexityViolation {
491            path: PathBuf::from("/project/src/a.ts"),
492            name: name.to_string(),
493            line: 1,
494            col: 0,
495            cyclomatic: match exceeded {
496                ExceededThreshold::Cyclomatic
497                | ExceededThreshold::Both
498                | ExceededThreshold::CyclomaticCrap
499                | ExceededThreshold::All => 25,
500                _ => 8,
501            },
502            cognitive: match exceeded {
503                ExceededThreshold::Cognitive
504                | ExceededThreshold::Both
505                | ExceededThreshold::CognitiveCrap
506                | ExceededThreshold::All => 20,
507                _ => 5,
508            },
509            line_count: 10,
510            param_count: 0,
511            react_hook_count: 0,
512            react_jsx_max_depth: 0,
513            react_prop_count: 0,
514            react_hook_profile: None,
515            exceeded,
516            severity: FindingSeverity::Moderate,
517            crap: exceeded.includes_crap().then_some(30.0),
518            coverage_pct: None,
519            coverage_tier: None,
520            coverage_source: None,
521            inherited_from: None,
522            component_rollup: None,
523            contributions: Vec::new(),
524            effective_thresholds: None,
525            threshold_source: None,
526        }
527    }
528
529    fn test_resolved_config() -> fallow_config::ResolvedConfig {
530        FallowConfig::default().resolve(
531            PathBuf::from("/project"),
532            OutputFormat::Json,
533            1,
534            true,
535            true,
536            None,
537        )
538    }
539
540    fn fx_summary(
541        tracked: usize,
542        hit: usize,
543        unhit: usize,
544        untracked: usize,
545    ) -> fallow_output::RuntimeCoverageSummary {
546        #[expect(
547            clippy::cast_precision_loss,
548            reason = "test fixture totals are tiny, f64 precision is fine"
549        )]
550        let coverage_percent = if tracked == 0 {
551            0.0
552        } else {
553            (hit as f64 / tracked as f64) * 100.0
554        };
555        fallow_output::RuntimeCoverageSummary {
556            data_source: fallow_output::RuntimeCoverageDataSource::Local,
557            last_received_at: None,
558            functions_tracked: tracked,
559            functions_hit: hit,
560            functions_unhit: unhit,
561            functions_untracked: untracked,
562            coverage_percent,
563            trace_count: 512,
564            period_days: 7,
565            deployments_seen: 2,
566            capture_quality: None,
567        }
568    }
569
570    fn fx_evidence(
571        static_status: &str,
572        test_coverage: &str,
573        v8_tracking: &str,
574    ) -> fallow_output::RuntimeCoverageEvidence {
575        fallow_output::RuntimeCoverageEvidence {
576            static_status: static_status.to_owned(),
577            test_coverage: test_coverage.to_owned(),
578            v8_tracking: v8_tracking.to_owned(),
579            untracked_reason: None,
580            observation_days: 7,
581            deployments_observed: 2,
582        }
583    }
584
585    fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
586        fallow_output::HealthScore {
587            formula_version: 2,
588            score,
589            grade,
590            penalties: fallow_output::HealthScorePenalties {
591                dead_files: None,
592                dead_exports: None,
593                complexity: 0.0,
594                p90_complexity: 0.0,
595                maintainability: None,
596                hotspots: None,
597                unused_deps: None,
598                circular_deps: None,
599                unit_size: None,
600                coupling: None,
601                duplication: None,
602                prop_drilling: None,
603            },
604        }
605    }
606
607    fn fx_gate_result(
608        findings: Vec<fallow_output::HealthFinding>,
609        score: Option<fallow_output::HealthScore>,
610    ) -> HealthResult {
611        HealthResult {
612            report: fallow_output::HealthReport {
613                findings,
614                health_score: score,
615                ..fallow_output::HealthReport::default()
616            },
617            grouping: None,
618            group_resolver: None,
619            config: test_resolved_config(),
620            workspace_diagnostics: Vec::new(),
621            elapsed: Duration::default(),
622            timings: None,
623            coverage_gaps_has_findings: false,
624            should_fail_on_coverage_gaps: false,
625        }
626    }
627
628    fn moderate_finding() -> fallow_output::HealthFinding {
629        make_finding("moderate", ExceededThreshold::Cyclomatic).into()
630    }
631
632    fn critical_finding() -> fallow_output::HealthFinding {
633        let mut v = make_finding("critical", ExceededThreshold::All);
634        v.severity = FindingSeverity::Critical;
635        v.into()
636    }
637
638    /// Helper: run the gate with the given flags, quiet, no report-only.
639    fn gate_exit(
640        result: &HealthResult,
641        min_score: Option<f64>,
642        min_severity: Option<FindingSeverity>,
643        report_only: bool,
644    ) -> ExitCode {
645        print_health_result(
646            result,
647            HealthPrintOptions {
648                quiet: true,
649                explain: false,
650                gates: HealthGateOptions {
651                    min_score,
652                    min_severity,
653                    report_only,
654                },
655                summary: false,
656                summary_heading: true,
657                show_explain_tip: true,
658                skip_score_and_trend: false,
659                css_requested: false,
660            },
661        )
662    }
663
664    #[test]
665    fn plain_health_with_findings_fails() {
666        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
667        assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
668    }
669
670    #[test]
671    fn plain_health_with_no_findings_succeeds() {
672        let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
673        assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
674    }
675
676    #[test]
677    fn min_score_zero_never_fails_even_with_findings() {
678        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
679        assert_eq!(
680            gate_exit(&result, Some(0.0), None, false),
681            ExitCode::SUCCESS
682        );
683    }
684
685    #[test]
686    fn min_score_passing_demotes_findings_to_informational() {
687        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
688        assert_eq!(
689            gate_exit(&result, Some(80.0), None, false),
690            ExitCode::SUCCESS
691        );
692    }
693
694    #[test]
695    fn min_score_below_threshold_fails() {
696        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
697        assert_eq!(
698            gate_exit(&result, Some(80.0), None, false),
699            ExitCode::from(1)
700        );
701    }
702
703    #[test]
704    fn min_severity_gates_on_severity_independent_of_min_score() {
705        let only_moderate =
706            fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
707        assert_eq!(
708            gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
709            ExitCode::SUCCESS,
710        );
711        let with_critical = fx_gate_result(
712            vec![moderate_finding(), critical_finding()],
713            Some(fx_health_score(87.5, "A")),
714        );
715        assert_eq!(
716            gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
717            ExitCode::from(1),
718        );
719    }
720
721    #[test]
722    fn min_score_and_min_severity_compose_as_or() {
723        let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
724        assert_eq!(
725            gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
726            ExitCode::SUCCESS,
727        );
728        let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
729        assert_eq!(
730            gate_exit(
731                &low_score,
732                Some(80.0),
733                Some(FindingSeverity::Critical),
734                false
735            ),
736            ExitCode::from(1),
737        );
738        let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
739        assert_eq!(
740            gate_exit(
741                &critical,
742                Some(80.0),
743                Some(FindingSeverity::Critical),
744                false
745            ),
746            ExitCode::from(1),
747        );
748    }
749
750    #[test]
751    fn report_only_never_fails_on_findings_or_low_score() {
752        let result = fx_gate_result(
753            vec![moderate_finding(), critical_finding()],
754            Some(fx_health_score(10.0, "F")),
755        );
756        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
757    }
758
759    #[test]
760    fn runtime_coverage_gate_independent_of_min_score() {
761        let result = fx_low_traffic_runtime_result();
762        assert_eq!(
763            gate_exit(&result, Some(0.0), None, false),
764            ExitCode::from(1)
765        );
766        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
767    }
768
769    fn fx_low_traffic_runtime_result() -> HealthResult {
770        HealthResult {
771            report: fallow_output::HealthReport {
772                runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
773                    schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
774                    verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
775                    signals: Vec::new(),
776                    summary: fx_summary(1, 0, 1, 0),
777                    findings: vec![fallow_output::RuntimeCoverageFinding {
778                        id: "fallow:prod:lowtraffic".to_owned(),
779                        stable_id: None,
780                        path: PathBuf::from("/project/src/cold.ts"),
781                        function: "coldPath".to_owned(),
782                        line: 14,
783                        verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
784                        invocations: Some(1),
785                        confidence: fallow_output::RuntimeCoverageConfidence::Low,
786                        evidence: fx_evidence("used", "not_covered", "tracked"),
787                        actions: vec![],
788                        source_hash: None,
789                        discriminators: None,
790                    }],
791                    hot_paths: vec![],
792                    blast_radius: vec![],
793                    importance: vec![],
794                    watermark: None,
795                    warnings: vec![],
796                    actionable: true,
797                    actionability_reason: None,
798                    actionability_verdict: None,
799                    provenance: fallow_output::RuntimeCoverageProvenance::default(),
800                }),
801                ..fallow_output::HealthReport::default()
802            },
803            grouping: None,
804            group_resolver: None,
805            config: test_resolved_config(),
806            workspace_diagnostics: Vec::new(),
807            elapsed: Duration::default(),
808            timings: None,
809            coverage_gaps_has_findings: false,
810            should_fail_on_coverage_gaps: false,
811        }
812    }
813
814    #[test]
815    fn print_health_result_fails_on_low_traffic_runtime_coverage() {
816        let result = fx_low_traffic_runtime_result();
817
818        assert_eq!(
819            print_health_result(
820                &result,
821                HealthPrintOptions {
822                    quiet: true,
823                    explain: false,
824                    gates: HealthGateOptions::default(),
825                    summary: false,
826                    summary_heading: true,
827                    show_explain_tip: true,
828                    skip_score_and_trend: false,
829                    css_requested: false,
830                },
831            ),
832            ExitCode::from(1),
833        );
834    }
835}