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