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