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 root 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 as scoring;
16
17use std::process::ExitCode;
18use std::time::Instant;
19
20use colored::Colorize;
21use fallow_config::OutputFormat;
22use fallow_engine::{
23    HealthExecutionOptions, HealthGateOptions, HealthGroupResolver, HealthPipelineInputs,
24    HealthScopeInputs, HealthSeams, HealthSharedParseData, HealthSort, RuntimeCoverageSeamInput,
25    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::RuntimeCoverageOptions,
127    input: RuntimeCoverageSeamInput<'_>,
128) -> Result<fallow_output::RuntimeCoverageReport, ExitCode> {
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/// Load config for a health run, validating coverage-root and churn-file inputs
174/// up front (loud exit 2 on a malformed input).
175fn load_health_config(
176    opts: &HealthOptions<'_>,
177) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
178    fallow_engine::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
179        .map_err(|e| emit_error(&e, 2, opts.output))?;
180    validate_health_churn_file(opts)?;
181    let t = Instant::now();
182    let config = crate::load_config_for_analysis(
183        opts.root,
184        opts.config_path,
185        crate::ConfigLoadOptions {
186            output: opts.output,
187            no_cache: opts.no_cache,
188            threads: opts.threads,
189            production_override: opts
190                .production_override
191                .or_else(|| opts.production.then_some(true)),
192            quiet: opts.quiet,
193        },
194        fallow_config::ProductionAnalysis::Health,
195    )?;
196    let config_ms = t.elapsed().as_secs_f64() * 1000.0;
197    Ok((config, config_ms))
198}
199
200/// Run health analysis using pre-parsed modules from the dead-code pipeline.
201///
202/// Skips file discovery and parsing (saves ~1.9s on 21K-file projects).
203pub fn execute_health_with_shared_parse(
204    opts: &HealthOptions<'_>,
205    shared: HealthSharedParseData,
206) -> Result<HealthResult, ExitCode> {
207    let (config, config_ms) = load_health_config(opts)?;
208    let scope_inputs = build_health_scope_inputs(opts, &config)?;
209    let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
210    let seams = health_seams();
211    let result = execute_health_inner(
212        opts,
213        HealthPipelineInputs {
214            config,
215            files: shared.files,
216            modules: shared.modules,
217            config_ms,
218            discover_ms: 0.0,
219            parse_ms: 0.0,
220            parse_cpu_ms: 0.0,
221            shared_parse: true,
222            pre_computed_analysis: shared.analysis_output,
223            workspace_diagnostics,
224        },
225        scope_inputs,
226        &seams,
227    )?;
228    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
229    Ok(result)
230}
231
232pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
233    let (config, config_ms) = load_health_config(opts)?;
234
235    let t = Instant::now();
236    let session = fallow_engine::AnalysisSession::from_resolved_config(config);
237    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
238    let session = session.into_parsed_parts(true);
239    let config = session.config;
240    let files = session.files;
241    let modules = session.modules;
242    let workspace_diagnostics = session.workspace_diagnostics;
243    let parse_ms = session.parse_ms;
244    let parse_cpu_ms = session.parse_cpu_ms;
245
246    let scope_inputs = build_health_scope_inputs(opts, &config)?;
247    let seams = health_seams();
248    let result = execute_health_inner(
249        opts,
250        HealthPipelineInputs {
251            config,
252            files,
253            modules,
254            config_ms,
255            discover_ms,
256            parse_ms,
257            parse_cpu_ms,
258            shared_parse: false,
259            pre_computed_analysis: None,
260            workspace_diagnostics,
261        },
262        scope_inputs,
263        &seams,
264    )?;
265    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
266    Ok(result)
267}
268
269pub fn run_health(opts: &HealthOptions<'_>) -> ExitCode {
270    let result = match execute_health(opts) {
271        Ok(r) => r,
272        Err(code) => return code,
273    };
274    if let Some(ref timings) = result.timings {
275        report::print_health_performance(timings, opts.output);
276    }
277    print_health_result(
278        &result,
279        HealthPrintOptions {
280            quiet: opts.quiet,
281            explain: opts.explain,
282            gates: opts.gates,
283            summary: opts.summary,
284            summary_heading: true,
285            show_explain_tip: true,
286            skip_score_and_trend: false,
287            css_requested: opts.css,
288        },
289    )
290}
291
292/// Result of executing health analysis without printing.
293pub type HealthResult = fallow_engine::HealthAnalysisResult<crate::report::OwnershipResolver>;
294
295/// Print health results and return appropriate exit code.
296///
297/// When called from combined mode (`fallow --score` / `fallow --trend`),
298/// `skip_score_and_trend` MUST be `true`: the orientation header already
299/// renders both blocks and rendering them a second time here would duplicate
300/// the lines. Standalone `fallow health` invocations pass `false`.
301///
302/// Exit-code gating (when `report_only` is `false`): the score gate
303/// (`--min-score`), the findings gate (`--min-severity`, or any finding when
304/// no gate flag is set), the runtime-coverage gate, and the coverage-gap gate
305/// are OR-combined. `report_only` short-circuits all of them to
306/// `ExitCode::SUCCESS` after rendering. Combined and audit callers pass
307/// `report_only: false` (they own their own gate semantics).
308///
309/// Callers that pass `min_score: Some(_)` must ensure
310/// `result.report.health_score` is `Some` (the CLI guarantees this because
311/// `--min-score` implies `--score`). If the score is missing the score gate
312/// cannot evaluate, so a direct API caller that requests a score gate without
313/// computing the score would get a permissive `ExitCode::SUCCESS`.
314#[derive(Clone, Copy)]
315pub struct HealthPrintOptions {
316    pub quiet: bool,
317    pub explain: bool,
318    pub gates: HealthGateOptions,
319    pub summary: bool,
320    pub summary_heading: bool,
321    pub show_explain_tip: bool,
322    pub skip_score_and_trend: bool,
323    /// Whether `--css` was requested. Forwarded to the human renderer so an empty
324    /// CSS result (no import-reachable stylesheet) is explained rather than
325    /// silently omitted. Defaults `false` for callers that do not request CSS.
326    pub css_requested: bool,
327}
328
329pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions) -> ExitCode {
330    let ctx = health_report_context(result, options);
331    let report_code = report::print_health_report(
332        &result.report,
333        result.grouping.as_ref(),
334        result.group_resolver.as_ref(),
335        &ctx,
336        result.config.output,
337    );
338    if report_code != ExitCode::SUCCESS {
339        return report_code;
340    }
341
342    if options.gates.report_only {
343        return ExitCode::SUCCESS;
344    }
345
346    if health_exit_gate_failed(result, options) {
347        return ExitCode::from(1);
348    }
349    if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
350        return ExitCode::from(1);
351    }
352    maybe_print_score_gate_note(result, options);
353
354    ExitCode::SUCCESS
355}
356
357fn health_report_context(
358    result: &HealthResult,
359    options: HealthPrintOptions,
360) -> report::ReportContext<'_> {
361    report::ReportContext {
362        root: &result.config.root,
363        rules: &result.config.rules,
364        elapsed: result.elapsed,
365        quiet: options.quiet,
366        explain: options.explain,
367        group_by: None,
368        top: None,
369        summary: options.summary,
370        summary_heading: options.summary_heading,
371        show_explain_tip: options.show_explain_tip,
372        baseline_matched: None,
373        config_fixable: false,
374        skip_score_and_trend: options.skip_score_and_trend,
375        css_requested: options.css_requested,
376    }
377}
378
379fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
380    score_gate_failed(result, options)
381        || findings_gate_failed(result, options)
382        || has_failing_runtime_coverage(result)
383}
384
385fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
386    let Some(threshold) = options.gates.min_score else {
387        return false;
388    };
389    let Some(ref hs) = result.report.health_score else {
390        return false;
391    };
392    if hs.score >= threshold {
393        return false;
394    }
395
396    if !options.quiet {
397        eprintln!(
398            "Health score {:.1} ({}) is below minimum threshold {:.0}",
399            hs.score, hs.grade, threshold
400        );
401    }
402    true
403}
404
405fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
406    if let Some(min_sev) = options.gates.min_severity {
407        result.report.findings.iter().any(|f| f.severity >= min_sev)
408    } else if options.gates.min_score.is_none() {
409        !result.report.findings.is_empty()
410    } else {
411        false
412    }
413}
414
415fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
416    result
417        .report
418        .runtime_coverage
419        .as_ref()
420        .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
421}
422
423fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
424    matches!(
425        finding.verdict,
426        fallow_output::RuntimeCoverageVerdict::SafeToDelete
427            | fallow_output::RuntimeCoverageVerdict::ReviewRequired
428            | fallow_output::RuntimeCoverageVerdict::LowTraffic
429    )
430}
431
432fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions) {
433    if options.gates.min_score.is_none()
434        || options.gates.min_severity.is_some()
435        || options.quiet
436        || result.report.findings.is_empty()
437        || !matches!(result.config.output, OutputFormat::Human)
438    {
439        return;
440    }
441
442    {
443        eprintln!(
444            "{}",
445            "Findings above are informational: --min-score gates on the score, not on findings."
446                .dimmed()
447        );
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use fallow_config::{FallowConfig, OutputFormat};
455    use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
456    use std::path::PathBuf;
457    use std::time::Duration;
458
459    fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
460        ComplexityViolation {
461            path: PathBuf::from("/project/src/a.ts"),
462            name: name.to_string(),
463            line: 1,
464            col: 0,
465            cyclomatic: match exceeded {
466                ExceededThreshold::Cyclomatic
467                | ExceededThreshold::Both
468                | ExceededThreshold::CyclomaticCrap
469                | ExceededThreshold::All => 25,
470                _ => 8,
471            },
472            cognitive: match exceeded {
473                ExceededThreshold::Cognitive
474                | ExceededThreshold::Both
475                | ExceededThreshold::CognitiveCrap
476                | ExceededThreshold::All => 20,
477                _ => 5,
478            },
479            line_count: 10,
480            param_count: 0,
481            react_hook_count: 0,
482            react_jsx_max_depth: 0,
483            react_prop_count: 0,
484            react_hook_profile: None,
485            exceeded,
486            severity: FindingSeverity::Moderate,
487            crap: exceeded.includes_crap().then_some(30.0),
488            coverage_pct: None,
489            coverage_tier: None,
490            coverage_source: None,
491            inherited_from: None,
492            component_rollup: None,
493            contributions: Vec::new(),
494            effective_thresholds: None,
495            threshold_source: None,
496        }
497    }
498
499    fn test_resolved_config() -> fallow_config::ResolvedConfig {
500        FallowConfig::default().resolve(
501            PathBuf::from("/project"),
502            OutputFormat::Json,
503            1,
504            true,
505            true,
506            None,
507        )
508    }
509
510    fn fx_summary(
511        tracked: usize,
512        hit: usize,
513        unhit: usize,
514        untracked: usize,
515    ) -> fallow_output::RuntimeCoverageSummary {
516        #[expect(
517            clippy::cast_precision_loss,
518            reason = "test fixture totals are tiny, f64 precision is fine"
519        )]
520        let coverage_percent = if tracked == 0 {
521            0.0
522        } else {
523            (hit as f64 / tracked as f64) * 100.0
524        };
525        fallow_output::RuntimeCoverageSummary {
526            data_source: fallow_output::RuntimeCoverageDataSource::Local,
527            last_received_at: None,
528            functions_tracked: tracked,
529            functions_hit: hit,
530            functions_unhit: unhit,
531            functions_untracked: untracked,
532            coverage_percent,
533            trace_count: 512,
534            period_days: 7,
535            deployments_seen: 2,
536            capture_quality: None,
537        }
538    }
539
540    fn fx_evidence(
541        static_status: &str,
542        test_coverage: &str,
543        v8_tracking: &str,
544    ) -> fallow_output::RuntimeCoverageEvidence {
545        fallow_output::RuntimeCoverageEvidence {
546            static_status: static_status.to_owned(),
547            test_coverage: test_coverage.to_owned(),
548            v8_tracking: v8_tracking.to_owned(),
549            untracked_reason: None,
550            observation_days: 7,
551            deployments_observed: 2,
552        }
553    }
554
555    fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
556        fallow_output::HealthScore {
557            formula_version: 2,
558            score,
559            grade,
560            penalties: fallow_output::HealthScorePenalties {
561                dead_files: None,
562                dead_exports: None,
563                complexity: 0.0,
564                p90_complexity: 0.0,
565                maintainability: None,
566                hotspots: None,
567                unused_deps: None,
568                circular_deps: None,
569                unit_size: None,
570                coupling: None,
571                duplication: None,
572                prop_drilling: None,
573            },
574        }
575    }
576
577    fn fx_gate_result(
578        findings: Vec<fallow_output::HealthFinding>,
579        score: Option<fallow_output::HealthScore>,
580    ) -> HealthResult {
581        HealthResult {
582            report: fallow_output::HealthReport {
583                findings,
584                health_score: score,
585                ..fallow_output::HealthReport::default()
586            },
587            grouping: None,
588            group_resolver: None,
589            config: test_resolved_config(),
590            workspace_diagnostics: Vec::new(),
591            elapsed: Duration::default(),
592            timings: None,
593            coverage_gaps_has_findings: false,
594            should_fail_on_coverage_gaps: false,
595        }
596    }
597
598    fn moderate_finding() -> fallow_output::HealthFinding {
599        make_finding("moderate", ExceededThreshold::Cyclomatic).into()
600    }
601
602    fn critical_finding() -> fallow_output::HealthFinding {
603        let mut v = make_finding("critical", ExceededThreshold::All);
604        v.severity = FindingSeverity::Critical;
605        v.into()
606    }
607
608    /// Helper: run the gate with the given flags, quiet, no report-only.
609    fn gate_exit(
610        result: &HealthResult,
611        min_score: Option<f64>,
612        min_severity: Option<FindingSeverity>,
613        report_only: bool,
614    ) -> ExitCode {
615        print_health_result(
616            result,
617            HealthPrintOptions {
618                quiet: true,
619                explain: false,
620                gates: HealthGateOptions {
621                    min_score,
622                    min_severity,
623                    report_only,
624                },
625                summary: false,
626                summary_heading: true,
627                show_explain_tip: true,
628                skip_score_and_trend: false,
629                css_requested: false,
630            },
631        )
632    }
633
634    #[test]
635    fn plain_health_with_findings_fails() {
636        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
637        assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
638    }
639
640    #[test]
641    fn plain_health_with_no_findings_succeeds() {
642        let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
643        assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
644    }
645
646    #[test]
647    fn min_score_zero_never_fails_even_with_findings() {
648        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
649        assert_eq!(
650            gate_exit(&result, Some(0.0), None, false),
651            ExitCode::SUCCESS
652        );
653    }
654
655    #[test]
656    fn min_score_passing_demotes_findings_to_informational() {
657        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
658        assert_eq!(
659            gate_exit(&result, Some(80.0), None, false),
660            ExitCode::SUCCESS
661        );
662    }
663
664    #[test]
665    fn min_score_below_threshold_fails() {
666        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
667        assert_eq!(
668            gate_exit(&result, Some(80.0), None, false),
669            ExitCode::from(1)
670        );
671    }
672
673    #[test]
674    fn min_severity_gates_on_severity_independent_of_min_score() {
675        let only_moderate =
676            fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
677        assert_eq!(
678            gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
679            ExitCode::SUCCESS,
680        );
681        let with_critical = fx_gate_result(
682            vec![moderate_finding(), critical_finding()],
683            Some(fx_health_score(87.5, "A")),
684        );
685        assert_eq!(
686            gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
687            ExitCode::from(1),
688        );
689    }
690
691    #[test]
692    fn min_score_and_min_severity_compose_as_or() {
693        let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
694        assert_eq!(
695            gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
696            ExitCode::SUCCESS,
697        );
698        let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
699        assert_eq!(
700            gate_exit(
701                &low_score,
702                Some(80.0),
703                Some(FindingSeverity::Critical),
704                false
705            ),
706            ExitCode::from(1),
707        );
708        let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
709        assert_eq!(
710            gate_exit(
711                &critical,
712                Some(80.0),
713                Some(FindingSeverity::Critical),
714                false
715            ),
716            ExitCode::from(1),
717        );
718    }
719
720    #[test]
721    fn report_only_never_fails_on_findings_or_low_score() {
722        let result = fx_gate_result(
723            vec![moderate_finding(), critical_finding()],
724            Some(fx_health_score(10.0, "F")),
725        );
726        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
727    }
728
729    #[test]
730    fn runtime_coverage_gate_independent_of_min_score() {
731        let result = fx_low_traffic_runtime_result();
732        assert_eq!(
733            gate_exit(&result, Some(0.0), None, false),
734            ExitCode::from(1)
735        );
736        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
737    }
738
739    fn fx_low_traffic_runtime_result() -> HealthResult {
740        HealthResult {
741            report: fallow_output::HealthReport {
742                runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
743                    schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
744                    verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
745                    signals: Vec::new(),
746                    summary: fx_summary(1, 0, 1, 0),
747                    findings: vec![fallow_output::RuntimeCoverageFinding {
748                        id: "fallow:prod:lowtraffic".to_owned(),
749                        stable_id: None,
750                        path: PathBuf::from("/project/src/cold.ts"),
751                        function: "coldPath".to_owned(),
752                        line: 14,
753                        verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
754                        invocations: Some(1),
755                        confidence: fallow_output::RuntimeCoverageConfidence::Low,
756                        evidence: fx_evidence("used", "not_covered", "tracked"),
757                        actions: vec![],
758                        source_hash: None,
759                        discriminators: None,
760                    }],
761                    hot_paths: vec![],
762                    blast_radius: vec![],
763                    importance: vec![],
764                    watermark: None,
765                    warnings: vec![],
766                    actionable: true,
767                    actionability_reason: None,
768                    actionability_verdict: None,
769                    provenance: fallow_output::RuntimeCoverageProvenance::default(),
770                }),
771                ..fallow_output::HealthReport::default()
772            },
773            grouping: None,
774            group_resolver: None,
775            config: test_resolved_config(),
776            workspace_diagnostics: Vec::new(),
777            elapsed: Duration::default(),
778            timings: None,
779            coverage_gaps_has_findings: false,
780            should_fail_on_coverage_gaps: false,
781        }
782    }
783
784    #[test]
785    fn print_health_result_fails_on_low_traffic_runtime_coverage() {
786        let result = fx_low_traffic_runtime_result();
787
788        assert_eq!(
789            print_health_result(
790                &result,
791                HealthPrintOptions {
792                    quiet: true,
793                    explain: false,
794                    gates: HealthGateOptions::default(),
795                    summary: false,
796                    summary_heading: true,
797                    show_explain_tip: true,
798                    skip_score_and_trend: false,
799                    css_requested: false,
800                },
801            ),
802            ExitCode::from(1),
803        );
804    }
805}