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;
12pub mod mutation_report;
13pub mod optimization_target;
14
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    HealthError, HealthExecutionOptions, HealthGateOptions, HealthGroupResolver,
26    HealthPipelineInputs, HealthScopeInputs, HealthSeams, HealthSharedParseData, HealthSort,
27    RuntimeCoverageSeamInput, execute_health_inner, validate_health_churn_file,
28};
29
30use crate::check::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
57/// CLI-only semantic overlay options for `health --type-coupling`.
58pub struct TypeAwareHealthOptions<'a> {
59    /// CLI override: `Some(true)` for `--type-aware`, `Some(false)` for
60    /// `--no-type-aware`, `None` when neither flag was passed.
61    pub enabled: Option<bool>,
62    pub requested: bool,
63    pub unfiltered: bool,
64    pub projects: &'a [std::path::PathBuf],
65    pub require: Option<fallow_config::TypeAwareRequire>,
66}
67
68impl HealthGroupResolver for OwnershipResolver {
69    fn mode_label(&self) -> &'static str {
70        OwnershipResolver::mode_label(self)
71    }
72
73    fn resolve_with_rule(&self, rel_path: &std::path::Path) -> (String, Option<String>) {
74        OwnershipResolver::resolve_with_rule(self, rel_path)
75    }
76
77    fn section_owners_of(&self, rel_path: &std::path::Path) -> Option<&[String]> {
78        OwnershipResolver::section_owners_of(self, rel_path)
79    }
80}
81
82/// Resolve the diff index for a health run: an explicit `--diff-file` index
83/// wins, otherwise the process-shared diff cache when the caller opted in.
84fn health_diff_index<'a>(opts: &HealthOptions<'a>) -> Option<&'a fallow_output::DiffIndex> {
85    match opts.diff_index {
86        Some(index) => Some(index),
87        None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(),
88        None => None,
89    }
90}
91
92/// Build the CODEOWNERS / package-backed grouping resolver for `--group-by`.
93fn build_health_group_resolver(
94    opts: &HealthOptions<'_>,
95    config: &fallow_config::ResolvedConfig,
96) -> Result<Option<OwnershipResolver>, ExitCode> {
97    crate::runtime_support::build_ownership_resolver_for_mode(
98        opts.group_by,
99        opts.root,
100        config.codeowners.as_deref(),
101        &config.workspace_patterns,
102        opts.output,
103    )
104}
105
106/// Record health telemetry from the finished report. Mirrors the per-analysis
107/// telemetry the other commands record; lives in the CLI because the telemetry
108/// sinks are process-global CLI state.
109fn record_health_telemetry(report: &fallow_output::HealthReport, coverage_gaps_has_findings: bool) {
110    if coverage_gaps_has_findings && report.findings.is_empty() {
111        crate::telemetry::note_findings_present(true);
112    } else {
113        crate::telemetry::note_result_count(report.findings.len());
114    }
115    crate::telemetry::note_analysis_scale(
116        Some(report.summary.files_analyzed),
117        Some(report.summary.functions_analyzed),
118    );
119}
120
121/// Build the engine seam callbacks: the runtime coverage sidecar adapter and
122/// the graph-structure telemetry hook.
123fn health_seams<'a>() -> HealthSeams<'a> {
124    HealthSeams {
125        runtime_coverage_analyzer: &runtime_coverage_seam,
126        note_graph_structure: &|module_count, edge_count| {
127            crate::telemetry::note_graph_structure_counts(module_count, edge_count);
128        },
129    }
130}
131
132/// Adapt the engine's runtime coverage seam input to the CLI coverage module,
133/// which owns the closed-source sidecar (license verification, subprocess
134/// spawning, signal handling).
135#[expect(
136    clippy::needless_pass_by_value,
137    reason = "by-value input matches the engine RuntimeCoverageAnalyzer seam signature"
138)]
139fn runtime_coverage_seam(
140    options: &fallow_engine::health::RuntimeCoverageOptions,
141    input: RuntimeCoverageSeamInput<'_>,
142) -> Result<fallow_output::RuntimeCoverageReport, u8> {
143    coverage::analyze(
144        options,
145        &coverage::RuntimeCoverageAnalysisInput {
146            root: input.root,
147            modules: input.modules,
148            analysis_output: input.analysis_output,
149            istanbul_coverage: input.istanbul_coverage,
150            file_paths: input.file_paths,
151            ignore_set: input.ignore_set,
152            changed_files: input.changed_files,
153            ws_roots: input.ws_roots,
154            top: input.top,
155            codeowners_path: input.codeowners_path,
156            quiet: input.quiet,
157            output: input.output,
158        },
159    )
160}
161
162/// Resolve the command-neutral scope inputs the engine needs: changed files,
163/// the diff index, workspace roots, and the grouping resolver.
164///
165/// `files` is the analyzed set, which sizes the scope an applied
166/// `--changed-since` leaves.
167fn build_health_scope_inputs<'a>(
168    opts: &HealthOptions<'a>,
169    config: &fallow_config::ResolvedConfig,
170    files: &[fallow_types::discover::DiscoveredFile],
171) -> Result<HealthScopeInputs<'a, OwnershipResolver>, ExitCode> {
172    let changed_files = opts
173        .changed_since
174        .and_then(|git_ref| crate::requests::resolve_changed_since(opts.root, git_ref));
175    crate::requests::measure_changed_since_scope(files);
176    let diff_index = health_diff_index(opts);
177    let mut ws_roots = resolve_workspace_scope(
178        opts.root,
179        opts.workspace,
180        opts.changed_workspaces,
181        &config.workspace_patterns,
182        opts.output,
183    )?;
184    if let Some(scope) = opts.scope.as_ref() {
185        ws_roots.get_or_insert_with(Vec::new).push(scope.clone());
186    }
187    let group_resolver = build_health_group_resolver(opts, config)?;
188    Ok(HealthScopeInputs {
189        changed_files,
190        diff_index,
191        ws_roots,
192        group_resolver,
193    })
194}
195
196/// Translate an engine [`HealthError`] into a CLI exit code at the command
197/// boundary. `Message` is rendered here (the engine no longer prints fatal
198/// errors); `Printed` was already emitted by a lower layer (the runtime-coverage
199/// seam), so its exit code is honored without a second error document.
200fn health_err_to_exit(error: HealthError, output: OutputFormat) -> ExitCode {
201    match error {
202        HealthError::Message { message, exit_code } => emit_error(&message, exit_code, output),
203        HealthError::Printed(code) => ExitCode::from(code),
204    }
205}
206
207/// Load config for a health run, validating coverage-root and churn-file inputs
208/// and the baseline destination up front (loud exit 2 on a malformed input).
209pub fn load_health_config(
210    opts: &HealthOptions<'_>,
211) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
212    if let Some(code) = crate::baseline_gate::refuse_save_before_analysis(
213        opts.save_baseline,
214        fallow_engine::baseline::BaselineKind::Health,
215        opts.output,
216    ) {
217        return Err(code);
218    }
219    fallow_engine::health::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
220        .map_err(|e| emit_error(&e, 2, opts.output))?;
221    validate_health_churn_file(opts).map_err(|e| health_err_to_exit(e, opts.output))?;
222    let t = Instant::now();
223    let mut config = crate::load_config_for_analysis(
224        opts.root,
225        opts.config_path,
226        crate::ConfigLoadOptions {
227            output: opts.output,
228            no_cache: opts.no_cache,
229            threads: opts.threads,
230            production_override:
231                fallow_engine::project_config::ProductionFlags::single_analysis_override(
232                    opts.production,
233                    opts.production_override,
234                ),
235            quiet: opts.quiet,
236            allow_remote_extends: opts.allow_remote_extends,
237        },
238        fallow_config::ProductionAnalysis::Health,
239    )?;
240    if opts.gates.fail_on_parse_error {
241        config.fail_on_parse_error = true;
242    }
243    let config_ms = t.elapsed().as_secs_f64() * 1000.0;
244    Ok((config, config_ms))
245}
246
247/// Run health analysis using pre-parsed modules from the dead-code pipeline.
248///
249/// Skips file discovery and parsing (saves ~1.9s on 21K-file projects).
250/// `pre_computed_duplication` is a duplication report over the same files and
251/// duplicates config; health then skips its own duplicate detection.
252pub fn execute_health_with_shared_parse(
253    opts: &HealthOptions<'_>,
254    shared: HealthSharedParseData,
255    pre_computed_duplication: Option<fallow_engine::duplicates::DuplicationReport>,
256) -> Result<HealthResult, ExitCode> {
257    let (config, config_ms) = load_health_config(opts)?;
258    let scope_inputs = build_health_scope_inputs(opts, &config, &shared.files)?;
259    let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
260    let workspaces = shared.workspaces;
261    let seams = health_seams();
262    let result = execute_health_inner(
263        opts,
264        HealthPipelineInputs {
265            config,
266            files: shared.files,
267            modules: shared.modules,
268            config_ms,
269            discover_ms: 0.0,
270            parse_ms: 0.0,
271            parse_cpu_ms: 0.0,
272            shared_parse: true,
273            pre_computed_analysis: shared.analysis_output,
274            dead_code_results: shared.dead_code_results,
275            styling_artifacts: None,
276            pre_computed_duplication,
277            workspaces,
278            workspace_diagnostics,
279        },
280        scope_inputs,
281        &seams,
282    )
283    .map_err(|e| health_err_to_exit(e, opts.output))?;
284    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
285    Ok(result)
286}
287
288pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
289    let (config, config_ms) = load_health_config(opts)?;
290    execute_health_with_config(opts, config, config_ms)
291}
292
293pub fn execute_health_with_config(
294    opts: &HealthOptions<'_>,
295    config: fallow_config::ResolvedConfig,
296    config_ms: f64,
297) -> Result<HealthResult, ExitCode> {
298    let seams = health_seams();
299    let result = execute_health_with_config_and_seams(opts, config, config_ms, &seams)?;
300    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
301    Ok(result)
302}
303
304fn execute_health_with_config_and_seams(
305    opts: &HealthOptions<'_>,
306    config: fallow_config::ResolvedConfig,
307    config_ms: f64,
308    seams: &HealthSeams<'_>,
309) -> Result<HealthResult, ExitCode> {
310    let t = Instant::now();
311    let session = fallow_engine::session::AnalysisSession::from_resolved_config(config)
312        .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
313    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
314    let parts = session.parsed_parts_uncached(true);
315    let pre_computed_analysis =
316        fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
317            .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
318            .transpose()
319            .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
320    let config = parts.config;
321    let files = parts.files;
322    let modules = parts.modules;
323    let workspaces = parts.workspaces;
324    let workspace_diagnostics = if pre_computed_analysis.is_some() {
325        session.current_workspace_diagnostics()
326    } else {
327        parts.workspace_diagnostics
328    };
329    let parse_ms = parts.parse_ms;
330    let parse_cpu_ms = parts.parse_cpu_ms;
331
332    let scope_inputs = build_health_scope_inputs(opts, &config, &files)?;
333    execute_health_inner(
334        opts,
335        HealthPipelineInputs {
336            config,
337            files,
338            modules,
339            config_ms,
340            discover_ms,
341            parse_ms,
342            parse_cpu_ms,
343            shared_parse: false,
344            dead_code_results: None,
345            styling_artifacts: None,
346            pre_computed_analysis,
347            pre_computed_duplication: None,
348            workspaces,
349            workspace_diagnostics,
350        },
351        scope_inputs,
352        seams,
353    )
354    .map_err(|e| health_err_to_exit(e, opts.output))
355}
356
357pub fn benchmark_execute_health_with_response(
358    opts: &HealthOptions<'_>,
359    response_bytes: &[u8],
360    request_len: &std::cell::Cell<usize>,
361) -> Result<HealthResult, ExitCode> {
362    let analyzer = |options: &fallow_engine::health::RuntimeCoverageOptions,
363                    input: RuntimeCoverageSeamInput<'_>| {
364        coverage::analyze_with_transport(
365            options,
366            &coverage::RuntimeCoverageAnalysisInput {
367                root: input.root,
368                modules: input.modules,
369                analysis_output: input.analysis_output,
370                istanbul_coverage: input.istanbul_coverage,
371                file_paths: input.file_paths,
372                ignore_set: input.ignore_set,
373                changed_files: input.changed_files,
374                ws_roots: input.ws_roots,
375                top: input.top,
376                codeowners_path: input.codeowners_path,
377                quiet: input.quiet,
378                output: input.output,
379            },
380            |request, _quiet, output| {
381                let (response, len) =
382                    coverage::in_process_response_transport(request, response_bytes, output)?;
383                request_len.set(len);
384                Ok(response)
385            },
386        )
387    };
388    let seams = HealthSeams {
389        runtime_coverage_analyzer: &analyzer,
390        note_graph_structure: &|_module_count, _edge_count| {},
391    };
392    let (config, config_ms) = load_health_config(opts)?;
393    execute_health_with_config_and_seams(opts, config, config_ms, &seams)
394}
395
396pub fn run_health(
397    opts: &HealthOptions<'_>,
398    json_style: crate::json_style::JsonStyle,
399    type_aware: &TypeAwareHealthOptions<'_>,
400    mutation_report: Option<&std::path::Path>,
401) -> ExitCode {
402    let mutation_evidence = match mutation_report.map(mutation_report::read_mutation_evidence) {
403        Some(Err(error)) => {
404            return crate::error::emit_programmatic_error(&error, opts.output, json_style);
405        }
406        evidence => evidence.and_then(Result::ok),
407    };
408    let (config, config_ms) = match load_health_config(opts) {
409        Ok(config) => config,
410        Err(code) => return code,
411    };
412    let resolved_type_aware = match resolve_type_aware_health_options(type_aware, &config) {
413        Ok(options) => options,
414        Err(message) => return emit_error(&message, 2, opts.output),
415    };
416    let requested = type_aware.requested || (type_aware.unfiltered && resolved_type_aware.enabled);
417    let mut degraded_meta = None;
418    let semantic = if requested {
419        let enabled = resolved_type_aware.enabled;
420        if !enabled {
421            return emit_error(
422                "--type-coupling requires --type-aware or typeAware.enabled in config",
423                2,
424                opts.output,
425            );
426        }
427        let projects = resolved_type_aware.projects;
428        let require = resolved_type_aware.require;
429        match fallow_api::analyze_type_coupling(opts.root, &projects, &[]) {
430            Ok(outcome) => Some(outcome),
431            Err(error) => {
432                match crate::type_aware_degrade::degrade_or_fail(
433                    &crate::type_aware_degrade::DegradeContext {
434                        root: opts.root,
435                        error: &error.to_string(),
436                        failure_label: "Type-aware coupling failed",
437                        require,
438                        quiet: opts.quiet,
439                        output: opts.output,
440                    },
441                ) {
442                    Ok(meta) => degraded_meta = Some(meta),
443                    Err(code) => return code,
444                }
445                None
446            }
447        }
448    } else {
449        None
450    };
451    let mut execution_opts = opts.clone();
452    if let Some(identity) = semantic
453        .as_ref()
454        .and_then(|outcome| outcome.type_aware.meta.identity.clone())
455    {
456        execution_opts.analysis_identity = identity;
457    }
458    let mut result = match execute_health_with_config(&execution_opts, config, config_ms) {
459        Ok(result) => result,
460        Err(code) => return code,
461    };
462    // The policy this run resolved from the flag, the environment or the
463    // config. The config alone misses `--type-aware-require`.
464    let required_completeness = resolved_type_aware.require.into();
465    result.type_aware_meta = semantic
466        .map(|outcome| {
467            let mut meta = outcome.type_aware.meta;
468            meta.required_completeness = Some(required_completeness);
469            meta
470        })
471        .or(degraded_meta);
472    if let Some(evidence) = mutation_evidence.as_ref() {
473        mutation_report::attach_to_result(&mut result, evidence);
474    }
475    if let Some(ref timings) = result.timings {
476        report::print_health_performance(timings, opts.output, json_style);
477    }
478    report_loaded_baseline(&mut result, opts.baseline);
479    if let Some(grouping) = result.grouping.as_ref() {
480        crate::requests::record_group_filter(grouping, opts.quiet);
481    }
482    // The standalone run owns the parse-error gate. The config already holds
483    // the flag, and it also holds the `failOnParseError` key.
484    let gates = fallow_engine::health::HealthGateOptions {
485        fail_on_parse_error: result.config.fail_on_parse_error,
486        ..opts.gates
487    };
488    print_health_result(
489        &result,
490        HealthPrintOptions {
491            quiet: opts.quiet,
492            explain: opts.explain,
493            gates,
494            baseline_path: opts.baseline,
495            summary: opts.summary,
496            summary_heading: true,
497            show_explain_tip: true,
498            type_aware_scope: None,
499            skip_score_and_trend: false,
500            css_requested: opts.css,
501            json_style,
502            exit_reason: true,
503        },
504    )
505}
506
507pub struct ResolvedTypeAwareHealthOptions {
508    pub enabled: bool,
509    pub projects: Vec<std::path::PathBuf>,
510    pub require: fallow_config::TypeAwareRequire,
511}
512
513pub fn resolve_type_aware_health_options(
514    options: &TypeAwareHealthOptions<'_>,
515    config: &fallow_config::ResolvedConfig,
516) -> Result<ResolvedTypeAwareHealthOptions, String> {
517    let env_enabled = std::env::var("FALLOW_TYPE_AWARE")
518        .ok()
519        .map(|value| match value.trim().to_ascii_lowercase().as_str() {
520            "1" | "true" | "yes" | "on" => Ok(true),
521            "0" | "false" | "no" | "off" => Ok(false),
522            _ => Err(
523                "FALLOW_TYPE_AWARE must be one of true, false, 1, 0, yes, no, on, or off"
524                    .to_string(),
525            ),
526        })
527        .transpose()?;
528    let enabled = options
529        .enabled
530        .or(env_enabled)
531        .unwrap_or(config.type_aware.enabled);
532    let projects = if !options.projects.is_empty() {
533        options.projects.to_vec()
534    } else if let Some(value) = std::env::var_os("FALLOW_TYPE_AWARE_PROJECTS") {
535        std::env::split_paths(&value).collect()
536    } else {
537        config
538            .type_aware
539            .projects
540            .iter()
541            .map(std::path::PathBuf::from)
542            .collect()
543    };
544    let require = if let Some(require) = options.require {
545        require
546    } else if let Ok(value) = std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
547        match value.trim().to_ascii_lowercase().as_str() {
548            "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
549            "complete" => fallow_config::TypeAwareRequire::Complete,
550            _ => {
551                return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete".to_string());
552            }
553        }
554    } else {
555        config.type_aware.require
556    };
557    Ok(ResolvedTypeAwareHealthOptions {
558        enabled,
559        projects,
560        require,
561    })
562}
563
564/// Result of executing health analysis without printing.
565pub type HealthResult =
566    fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
567
568/// Print health results and return appropriate exit code.
569///
570/// When called from combined mode (`fallow --score` / `fallow --trend`),
571/// `skip_score_and_trend` MUST be `true`: the orientation header already
572/// renders both blocks and rendering them a second time here would duplicate
573/// the lines. Standalone `fallow health` invocations pass `false`.
574///
575/// Exit-code gating (when `report_only` is `false`): the score gate
576/// (`--min-score`), the findings gate (`--min-severity`, or any finding when
577/// no gate flag is set), the runtime-coverage gate, the opt-in stale-baseline
578/// gate (`--fail-on-stale-baseline`) and the coverage-gap gate are
579/// OR-combined. `report_only` short-circuits all of them to
580/// `ExitCode::SUCCESS` after rendering. Combined and audit callers pass
581/// `report_only: false` (they own their own gate semantics).
582///
583/// Callers that pass `min_score: Some(_)` must ensure
584/// `result.report.health_score` is `Some` (the CLI guarantees this because
585/// `--min-score` implies `--score`). If the score is missing the score gate
586/// cannot evaluate, so a direct API caller that requests a score gate without
587/// computing the score would get a permissive `ExitCode::SUCCESS`.
588#[derive(Clone, Copy)]
589pub struct HealthPrintOptions<'a> {
590    pub quiet: bool,
591    pub explain: bool,
592    pub gates: HealthGateOptions,
593    /// Loaded `--baseline` path, so the stale-baseline gate can name the file
594    /// to re-save. `None` when no baseline was loaded, which makes the gate
595    /// inert.
596    pub baseline_path: Option<&'a std::path::Path>,
597    pub summary: bool,
598    pub summary_heading: bool,
599    pub show_explain_tip: bool,
600    pub type_aware_scope: Option<&'static str>,
601    pub skip_score_and_trend: bool,
602    /// Whether `--css` was requested. Forwarded to the human renderer so an empty
603    /// CSS result (no import-reachable stylesheet) is explained rather than
604    /// silently omitted. Defaults `false` for callers that do not request CSS.
605    pub css_requested: bool,
606    pub json_style: crate::json_style::JsonStyle,
607    /// Print the stderr line that names the gates behind a non-zero exit. False
608    /// when the caller owns the exit code, as `audit` does, or prints one line
609    /// for all its sections, as the bare run does.
610    pub exit_reason: bool,
611}
612
613pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions<'_>) -> ExitCode {
614    let ctx = health_report_context(result, options);
615    let report_code = report::print_health_report(
616        &result.report,
617        result.grouping.as_ref(),
618        result.group_resolver.as_ref(),
619        &ctx,
620        result.config.output,
621    );
622    if report_code != ExitCode::SUCCESS {
623        return report_code;
624    }
625
626    // `--report-only` keeps every gate advisory except type-aware completeness,
627    // which the envelope always states as enforced.
628    let type_aware_code = crate::exit_codes::gate_failed_exit_code(
629        fallow_output::GateName::TypeAwareRequire,
630        type_aware_completeness_incomplete(result),
631    );
632    let code = if options.gates.report_only {
633        note_stale_baseline_gate_stood_down(result, options);
634        type_aware_code
635    } else {
636        health_exit_code(result, options).max(type_aware_code)
637    };
638    if code == 0 {
639        if !options.gates.report_only {
640            maybe_print_score_gate_note(result, options);
641        }
642    } else if options.exit_reason {
643        crate::gates::print_exit_reason(&crate::gates::ExitReason {
644            gates: health_gate_outcomes(result, options).as_ref(),
645            code,
646            fail_on_issues: options.gates.fail_on_issues,
647            quiet: options.quiet,
648            output: result.config.output,
649            own_lines: &[],
650        });
651    }
652    crate::exit_codes::run_exit_code([code])
653}
654
655/// Whether the type-aware completeness gate of this health result fails.
656/// The health print and the gate of the bare run both read this rule.
657pub fn type_aware_completeness_incomplete(result: &HealthResult) -> bool {
658    crate::report::ci::required_type_aware_incomplete(result.type_aware_meta.as_ref())
659}
660
661fn health_report_context<'a>(
662    result: &'a HealthResult,
663    options: HealthPrintOptions<'a>,
664) -> report::ReportContext<'a> {
665    report::ReportContext {
666        package_baselines: &[],
667        root: &result.config.root,
668        rules: &result.config.rules,
669        workspace_diagnostics: &result.workspace_diagnostics,
670        elapsed: result.elapsed,
671        quiet: options.quiet,
672        explain: options.explain,
673        type_aware: result.type_aware_meta.as_ref(),
674        type_aware_scope: options.type_aware_scope,
675        group_by: None,
676        top: None,
677        summary: options.summary,
678        summary_heading: options.summary_heading,
679        show_explain_tip: options.show_explain_tip,
680        baseline_matched: None,
681        baseline_staleness: None,
682        finding_id_query: None,
683        gate_outcomes: health_gate_outcomes(result, options),
684        config_fixable: false,
685        failed_parse_files: 0,
686        skip_score_and_trend: options.skip_score_and_trend,
687        css_requested: options.css_requested,
688        json_style: options.json_style,
689        include_fragments: true,
690        architecture_layout: report::ArchitectureLayout::Embedded,
691    }
692}
693
694/// The gates a health run armed, for the envelope's `gate_outcomes`. The
695/// verdicts read the same values as the exit path.
696fn health_gate_outcomes(
697    result: &HealthResult,
698    options: HealthPrintOptions<'_>,
699) -> Option<fallow_output::GateOutcomes> {
700    crate::gates::health_gate_outcomes(&crate::gates::HealthGateInputs {
701        report_only: options.gates.report_only,
702        min_score: options.gates.min_score.map(|threshold| {
703            (
704                threshold,
705                result.report.health_score.as_ref().map(|score| score.score),
706            )
707        }),
708        min_severity: options.gates.min_severity.map(|floor| {
709            (
710                floor,
711                blocking_findings(result)
712                    .filter(|finding| finding.severity >= floor)
713                    .count(),
714            )
715        }),
716        coverage_gaps: result
717            .should_fail_on_coverage_gaps
718            .then_some(result.coverage_gaps_has_findings),
719        runtime_coverage: result
720            .report
721            .runtime_coverage
722            .is_some()
723            .then(|| has_failing_runtime_coverage(result)),
724        baseline_staleness: result.report.summary.baseline_staleness.as_ref(),
725        fail_on_stale_baseline: options.gates.fail_on_stale_baseline,
726        blocking_findings: blocking_findings(result).count(),
727        type_aware_meta: result.type_aware_meta.as_ref(),
728        parse_error: health_parse_error_outcome(result, options),
729    })
730}
731
732/// The `parse-error` gate of a health run, `None` unless this print owns the
733/// gate and the gate is armed. Built with `enforced: true`; the gate builder
734/// clamps it under `--report-only`.
735fn health_parse_error_outcome(
736    result: &HealthResult,
737    options: HealthPrintOptions<'_>,
738) -> Option<fallow_output::GateOutcome> {
739    crate::gates::parse_error_outcome(
740        options.gates.fail_on_parse_error,
741        true,
742        &crate::gates::parse_degraded_files(&result.config.root, &result.workspace_diagnostics),
743    )
744}
745
746/// The exit code of every health exit gate, with each gate evaluated before
747/// the codes are combined so that none of them can swallow another's stderr
748/// line.
749///
750/// The baseline gate is why this is not a short-circuiting chain: the score and
751/// findings gates have their condition in the human report and in the
752/// exit-reason line, a stale baseline has it nowhere, so a run that already
753/// fails the findings gate would exit 1 with nothing about the baseline the
754/// user explicitly gated on.
755fn health_exit_code(result: &HealthResult, options: HealthPrintOptions<'_>) -> u8 {
756    use crate::exit_codes::gate_failed_exit_code;
757    use fallow_output::GateName;
758
759    let findings_gate = if options.gates.min_severity.is_some() {
760        GateName::HealthMinSeverity
761    } else {
762        GateName::HealthFindings
763    };
764    let parse_error = health_parse_error_outcome(result, options);
765    let parse_error_failed = parse_error
766        .as_ref()
767        .is_some_and(fallow_output::GateOutcome::fails_run);
768    if let Some(outcome) = parse_error.as_ref().filter(|_| parse_error_failed) {
769        crate::gates::print_parse_error_gate_failure(&outcome.files);
770    }
771    [
772        gate_failed_exit_code(GateName::HealthMinScore, score_gate_failed(result, options)),
773        gate_failed_exit_code(findings_gate, findings_gate_failed(result, options)),
774        gate_failed_exit_code(
775            GateName::HealthRuntimeCoverage,
776            has_failing_runtime_coverage(result),
777        ),
778        gate_failed_exit_code(
779            GateName::StaleBaseline,
780            stale_baseline_gate_failed(result, options),
781        ),
782        gate_failed_exit_code(
783            GateName::HealthCoverageGaps,
784            result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings,
785        ),
786        gate_failed_exit_code(GateName::ParseError, parse_error_failed),
787    ]
788    .into_iter()
789    .max()
790    .unwrap_or(0)
791}
792
793/// Say what this run made of the loaded baseline, and record it for the
794/// `recheck-baseline` next step.
795///
796/// Both happen here rather than at the engine's load site, which cannot reach
797/// CLI runtime state or print a CLI note, and only for the standalone command:
798/// `audit` and the combined run build their next steps from their own builders
799/// and would otherwise offer a `health` path on an envelope that is not
800/// health's.
801/// The note also records the writer in `baseline_staleness.saved_by`, which
802/// the gate line reads, so the note, the gate line and the envelope agree.
803fn report_loaded_baseline(result: &mut HealthResult, baseline_path: Option<&std::path::Path>) {
804    let Some(path) = baseline_path else {
805        return;
806    };
807    note_unrecognised_health_baseline(result, baseline_path, "--baseline");
808    let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
809        return;
810    };
811    crate::output_runtime::set_loaded_baseline(crate::output_runtime::LoadedBaselineRecheck {
812        command: "health",
813        path: path.display().to_string(),
814        baseline_entries: staleness.baseline_entries,
815        scope_reasons: staleness.scope_reasons,
816    });
817}
818
819/// Say that the loaded baseline is not a health baseline.
820///
821/// Split from [`report_loaded_baseline`] because `fallow audit` needs the note
822/// and must not get the `recheck-baseline` record beside it: the audit envelope
823/// is not health's, so a `fallow health` next step on it would point at the
824/// wrong report. `dupes` and `dead-code` reach their notes through their own
825/// load sites, which audit shares.
826///
827/// `flag` is the argument that carried the path: `--health-baseline` on an audit
828/// and `--baseline` on the standalone command.
829///
830/// The engine classifies the file as a health baseline only, and the bytes are
831/// gone when the CLI prints. So this function reads the file one time, finds
832/// the known command that saved it, and writes that command into
833/// `baseline_staleness.saved_by`. The note, the gate line and the envelope all
834/// read that one value.
835pub fn note_unrecognised_health_baseline(
836    result: &mut HealthResult,
837    baseline_path: Option<&std::path::Path>,
838    flag: &str,
839) {
840    let Some(path) = baseline_path else {
841        return;
842    };
843    let Some(staleness) = result.report.summary.baseline_staleness.as_mut() else {
844        return;
845    };
846    if !staleness.unrecognised_format {
847        return;
848    }
849    let saved_by = saved_by_another_command(path);
850    staleness.saved_by = saved_by.map(fallow_engine::baseline::BaselineKind::as_str);
851    crate::baseline_gate::note_unrecognised_baseline(
852        Some(path),
853        true,
854        saved_by,
855        fallow_engine::baseline::BaselineKind::Health,
856        flag,
857    );
858}
859
860/// The known command that saved a baseline file, when it is not `health`.
861/// `None` for a file that names no known writer, which includes every baseline
862/// saved before `kind` existed, and for a file that can no longer be read.
863fn saved_by_another_command(
864    path: &std::path::Path,
865) -> Option<fallow_engine::baseline::BaselineKind> {
866    let content = std::fs::read_to_string(path).ok()?;
867    fallow_engine::baseline::classify_baseline_file(
868        &content,
869        fallow_engine::baseline::BaselineKind::Health,
870    )
871    .saved_by()
872}
873
874/// Say that `--report-only` suppressed the gate, so a job that passes both
875/// flags learns its baseline was never judged instead of going green forever.
876///
877/// `--report-only` is an explicit request never to fail the run, so the gate
878/// obeys it rather than overriding it; it just does not obey it in silence.
879fn note_stale_baseline_gate_stood_down(result: &HealthResult, options: HealthPrintOptions<'_>) {
880    let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
881        return;
882    };
883    // A baseline with no entries gives the gate nothing to judge, so no gate
884    // stands down. A file this command cannot read as its own carries the same
885    // zero, and there the gate rule holds. `--report-only` then suppresses a real
886    // verdict, and it must say so.
887    if staleness.baseline_entries == 0 && !staleness.unrecognised_format {
888        return;
889    }
890    crate::baseline_gate::note_stood_down(
891        options.baseline_path,
892        options.gates.fail_on_stale_baseline,
893        "--report-only never fails a run",
894    );
895}
896
897/// The opt-in `--fail-on-stale-baseline` gate. Reads the staleness the engine
898/// already put in the report, so no extra plumbing crosses the engine boundary.
899fn stale_baseline_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
900    let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
901        return false;
902    };
903    crate::baseline_gate::gate_failed_from_envelope(
904        staleness,
905        options.baseline_path,
906        options.gates.fail_on_stale_baseline,
907        fallow_engine::baseline::BaselineKind::Health,
908    )
909}
910
911fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
912    let Some(threshold) = options.gates.min_score else {
913        return false;
914    };
915    let Some(ref hs) = result.report.health_score else {
916        return false;
917    };
918    if hs.score >= threshold {
919        return false;
920    }
921
922    // The exit-reason line states this gate under `--quiet` and in the machine
923    // formats, so this line is for the human report only.
924    if !options.quiet && matches!(result.config.output, fallow_config::OutputFormat::Human) {
925        eprintln!(
926            "Health score {:.1} ({}) is below minimum threshold {:.0}",
927            hs.score, hs.grade, threshold
928        );
929    }
930    true
931}
932
933fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
934    if let Some(min_sev) = options.gates.min_severity {
935        blocking_findings(result).any(|f| f.severity >= min_sev)
936    } else if options.gates.min_score.is_none() {
937        blocking_findings(result).next().is_some()
938    } else {
939        false
940    }
941}
942
943/// The findings whose `complexity-*` rule is `error`.
944///
945/// The rule applies first. `--min-severity` then filters these findings by
946/// band, so a `warn` finding never fails the run, whatever its band.
947fn blocking_findings(result: &HealthResult) -> impl Iterator<Item = &fallow_output::HealthFinding> {
948    result
949        .report
950        .findings
951        .iter()
952        .filter(|finding| finding.blocks())
953}
954
955fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
956    result
957        .report
958        .runtime_coverage
959        .as_ref()
960        .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
961}
962
963fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
964    matches!(
965        finding.verdict,
966        fallow_output::RuntimeCoverageVerdict::SafeToDelete
967            | fallow_output::RuntimeCoverageVerdict::ReviewRequired
968            | fallow_output::RuntimeCoverageVerdict::LowTraffic
969    )
970}
971
972fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions<'_>) {
973    if options.gates.min_score.is_none()
974        || options.gates.min_severity.is_some()
975        || options.quiet
976        || result.report.findings.is_empty()
977        || !matches!(result.config.output, OutputFormat::Human)
978    {
979        return;
980    }
981
982    {
983        eprintln!(
984            "{}",
985            "Findings above are informational: --min-score gates on the score, not on findings."
986                .dimmed()
987        );
988    }
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use fallow_config::{FallowConfig, OutputFormat};
995    use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
996    use std::path::PathBuf;
997    use std::time::Duration;
998
999    fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
1000        ComplexityViolation {
1001            path: PathBuf::from("/project/src/a.ts"),
1002            name: name.to_string(),
1003            line: 1,
1004            col: 0,
1005            cyclomatic: match exceeded {
1006                ExceededThreshold::Cyclomatic
1007                | ExceededThreshold::Both
1008                | ExceededThreshold::CyclomaticCrap
1009                | ExceededThreshold::All => 25,
1010                _ => 8,
1011            },
1012            cognitive: match exceeded {
1013                ExceededThreshold::Cognitive
1014                | ExceededThreshold::Both
1015                | ExceededThreshold::CognitiveCrap
1016                | ExceededThreshold::All => 20,
1017                _ => 5,
1018            },
1019            line_count: 10,
1020            param_count: 0,
1021            react_hook_count: 0,
1022            react_jsx_max_depth: 0,
1023            react_prop_count: 0,
1024            react_hook_profile: None,
1025            exceeded,
1026            effective_severity: None,
1027            severity: FindingSeverity::Moderate,
1028            crap: exceeded.includes_crap().then_some(30.0),
1029            mutation: None,
1030            coverage_pct: None,
1031            coverage_tier: None,
1032            coverage_source: None,
1033            inherited_from: None,
1034            component_rollup: None,
1035            contributions: Vec::new(),
1036            effective_thresholds: None,
1037            threshold_source: None,
1038        }
1039    }
1040
1041    fn test_resolved_config() -> fallow_config::ResolvedConfig {
1042        FallowConfig::default().resolve(
1043            PathBuf::from("/project"),
1044            OutputFormat::Json,
1045            1,
1046            true,
1047            true,
1048            None,
1049        )
1050    }
1051
1052    fn fx_summary(
1053        tracked: usize,
1054        hit: usize,
1055        unhit: usize,
1056        untracked: usize,
1057    ) -> fallow_output::RuntimeCoverageSummary {
1058        #[expect(
1059            clippy::cast_precision_loss,
1060            reason = "test fixture totals are tiny, f64 precision is fine"
1061        )]
1062        let coverage_percent = if tracked == 0 {
1063            0.0
1064        } else {
1065            (hit as f64 / tracked as f64) * 100.0
1066        };
1067        fallow_output::RuntimeCoverageSummary {
1068            data_source: fallow_output::RuntimeCoverageDataSource::Local,
1069            last_received_at: None,
1070            functions_tracked: tracked,
1071            functions_hit: hit,
1072            functions_unhit: unhit,
1073            functions_untracked: untracked,
1074            coverage_percent,
1075            trace_count: 512,
1076            period_days: 7,
1077            deployments_seen: 2,
1078            capture_quality: None,
1079        }
1080    }
1081
1082    fn fx_evidence(
1083        static_status: &str,
1084        test_coverage: &str,
1085        v8_tracking: &str,
1086    ) -> fallow_output::RuntimeCoverageEvidence {
1087        fallow_output::RuntimeCoverageEvidence {
1088            static_status: static_status.to_owned(),
1089            test_coverage: test_coverage.to_owned(),
1090            test_only_reference: None,
1091            v8_tracking: v8_tracking.to_owned(),
1092            untracked_reason: None,
1093            observation_days: 7,
1094            deployments_observed: 2,
1095        }
1096    }
1097
1098    fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
1099        fallow_output::HealthScore {
1100            formula_version: 2,
1101            score,
1102            grade,
1103            penalties: fallow_output::HealthScorePenalties {
1104                dead_files: None,
1105                dead_exports: None,
1106                complexity: 0.0,
1107                p90_complexity: 0.0,
1108                maintainability: None,
1109                hotspots: None,
1110                unused_deps: None,
1111                circular_deps: None,
1112                unit_size: None,
1113                coupling: None,
1114                duplication: None,
1115                prop_drilling: None,
1116            },
1117        }
1118    }
1119
1120    fn fx_gate_result(
1121        findings: Vec<fallow_output::HealthFinding>,
1122        score: Option<fallow_output::HealthScore>,
1123    ) -> HealthResult {
1124        HealthResult {
1125            branching_by_file: fallow_engine::health::BranchingByFile::default(),
1126            report: fallow_output::HealthReport {
1127                findings,
1128                health_score: score,
1129                ..fallow_output::HealthReport::default()
1130            },
1131            grouping: None,
1132            group_resolver: None,
1133            config: test_resolved_config(),
1134            workspace_diagnostics: Vec::new(),
1135            elapsed: Duration::default(),
1136            timings: None,
1137            type_aware_meta: None,
1138            coverage_gaps_has_findings: false,
1139            should_fail_on_coverage_gaps: false,
1140            changed_files_analyzed: None,
1141        }
1142    }
1143
1144    fn moderate_finding() -> fallow_output::HealthFinding {
1145        make_finding("moderate", ExceededThreshold::Cyclomatic).into()
1146    }
1147
1148    fn critical_finding() -> fallow_output::HealthFinding {
1149        let mut v = make_finding("critical", ExceededThreshold::All);
1150        v.severity = FindingSeverity::Critical;
1151        v.into()
1152    }
1153
1154    /// Helper: run the gate with the given flags, quiet, no report-only.
1155    fn gate_exit(
1156        result: &HealthResult,
1157        min_score: Option<f64>,
1158        min_severity: Option<FindingSeverity>,
1159        report_only: bool,
1160    ) -> ExitCode {
1161        print_health_result(
1162            result,
1163            HealthPrintOptions {
1164                quiet: true,
1165                explain: false,
1166                gates: HealthGateOptions {
1167                    min_score,
1168                    min_severity,
1169                    report_only,
1170                    fail_on_stale_baseline: false,
1171                    fail_on_parse_error: false,
1172                    fail_on_issues: false,
1173                },
1174                baseline_path: None,
1175                summary: false,
1176                summary_heading: true,
1177                show_explain_tip: true,
1178                type_aware_scope: None,
1179                skip_score_and_trend: false,
1180                css_requested: false,
1181                json_style: crate::json_style::JsonStyle::Compact,
1182                exit_reason: true,
1183            },
1184        )
1185    }
1186
1187    #[test]
1188    fn plain_health_with_findings_fails() {
1189        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1190        assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1191    }
1192
1193    #[test]
1194    fn plain_health_with_no_findings_succeeds() {
1195        let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1196        assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
1197    }
1198
1199    #[test]
1200    fn min_score_zero_never_fails_even_with_findings() {
1201        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1202        assert_eq!(
1203            gate_exit(&result, Some(0.0), None, false),
1204            ExitCode::SUCCESS
1205        );
1206    }
1207
1208    #[test]
1209    fn min_score_passing_demotes_findings_to_informational() {
1210        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1211        assert_eq!(
1212            gate_exit(&result, Some(80.0), None, false),
1213            ExitCode::SUCCESS
1214        );
1215    }
1216
1217    #[test]
1218    fn min_score_below_threshold_fails() {
1219        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1220        assert_eq!(
1221            gate_exit(&result, Some(80.0), None, false),
1222            ExitCode::from(1)
1223        );
1224    }
1225
1226    #[test]
1227    fn min_severity_gates_on_severity_independent_of_min_score() {
1228        let only_moderate =
1229            fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1230        assert_eq!(
1231            gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
1232            ExitCode::SUCCESS,
1233        );
1234        let with_critical = fx_gate_result(
1235            vec![moderate_finding(), critical_finding()],
1236            Some(fx_health_score(87.5, "A")),
1237        );
1238        assert_eq!(
1239            gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
1240            ExitCode::from(1),
1241        );
1242    }
1243
1244    #[test]
1245    fn min_score_and_min_severity_compose_as_or() {
1246        let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1247        assert_eq!(
1248            gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
1249            ExitCode::SUCCESS,
1250        );
1251        let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1252        assert_eq!(
1253            gate_exit(
1254                &low_score,
1255                Some(80.0),
1256                Some(FindingSeverity::Critical),
1257                false
1258            ),
1259            ExitCode::from(1),
1260        );
1261        let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
1262        assert_eq!(
1263            gate_exit(
1264                &critical,
1265                Some(80.0),
1266                Some(FindingSeverity::Critical),
1267                false
1268            ),
1269            ExitCode::from(1),
1270        );
1271    }
1272
1273    #[test]
1274    fn report_only_never_fails_on_findings_or_low_score() {
1275        let result = fx_gate_result(
1276            vec![moderate_finding(), critical_finding()],
1277            Some(fx_health_score(10.0, "F")),
1278        );
1279        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1280    }
1281
1282    #[test]
1283    fn runtime_coverage_gate_independent_of_min_score() {
1284        let result = fx_low_traffic_runtime_result();
1285        assert_eq!(
1286            gate_exit(&result, Some(0.0), None, false),
1287            ExitCode::from(1)
1288        );
1289        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1290    }
1291
1292    fn fx_low_traffic_runtime_result() -> HealthResult {
1293        HealthResult {
1294            branching_by_file: fallow_engine::health::BranchingByFile::default(),
1295            report: fallow_output::HealthReport {
1296                runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
1297                    schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
1298                    verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
1299                    signals: Vec::new(),
1300                    summary: fx_summary(1, 0, 1, 0),
1301                    findings: vec![fallow_output::RuntimeCoverageFinding {
1302                        id: "fallow:prod:lowtraffic".to_owned(),
1303                        stable_id: None,
1304                        path: PathBuf::from("/project/src/cold.ts"),
1305                        function: "coldPath".to_owned(),
1306                        line: 14,
1307                        verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
1308                        invocations: Some(1),
1309                        confidence: fallow_output::RuntimeCoverageConfidence::Low,
1310                        evidence: fx_evidence("used", "not_covered", "tracked"),
1311                        actions: vec![],
1312                        source_hash: None,
1313                        discriminators: None,
1314                    }],
1315                    hot_paths: vec![],
1316                    blast_radius: vec![],
1317                    importance: vec![],
1318                    watermark: None,
1319                    warnings: vec![],
1320                    actionable: true,
1321                    actionability_reason: None,
1322                    actionability_verdict: None,
1323                    provenance: fallow_output::RuntimeCoverageProvenance::default(),
1324                }),
1325                ..fallow_output::HealthReport::default()
1326            },
1327            grouping: None,
1328            group_resolver: None,
1329            config: test_resolved_config(),
1330            workspace_diagnostics: Vec::new(),
1331            elapsed: Duration::default(),
1332            timings: None,
1333            type_aware_meta: None,
1334            coverage_gaps_has_findings: false,
1335            should_fail_on_coverage_gaps: false,
1336            changed_files_analyzed: None,
1337        }
1338    }
1339
1340    #[test]
1341    fn print_health_result_fails_on_low_traffic_runtime_coverage() {
1342        let result = fx_low_traffic_runtime_result();
1343
1344        assert_eq!(
1345            print_health_result(
1346                &result,
1347                HealthPrintOptions {
1348                    quiet: true,
1349                    explain: false,
1350                    gates: HealthGateOptions::default(),
1351                    baseline_path: None,
1352                    summary: false,
1353                    summary_heading: true,
1354                    show_explain_tip: true,
1355                    type_aware_scope: None,
1356                    skip_score_and_trend: false,
1357                    css_requested: false,
1358                    json_style: crate::json_style::JsonStyle::Compact,
1359                    exit_reason: true,
1360                },
1361            ),
1362            ExitCode::from(1),
1363        );
1364    }
1365
1366    /// A type-aware metadata block that asks for the `complete` policy and
1367    /// holds a partial query, so the completeness gate fails.
1368    fn incomplete_required_type_aware_meta() -> fallow_types::envelope::TypeAwareMeta {
1369        fallow_types::envelope::TypeAwareMeta {
1370            required_completeness: Some(
1371                fallow_types::semantic::SemanticCompletenessRequirement::Complete,
1372            ),
1373            queries: vec![fallow_types::semantic::SemanticQuerySummary {
1374                query_id: 0,
1375                capability: fallow_types::semantic::SemanticCapability::TypeCoupling,
1376                assertion: "type coupling".to_string(),
1377                status: fallow_types::semantic::SemanticCompleteness::Partial,
1378                reason_code: None,
1379                total_evidence_count: 0,
1380                truncated: false,
1381                omissions: Vec::new(),
1382                actions: Vec::new(),
1383            }],
1384            ..Default::default()
1385        }
1386    }
1387
1388    /// The type-aware completeness gate is part of the health exit path, so
1389    /// the exit-reason line names it when it is the only failed gate. It also
1390    /// fails a `--report-only` run, which keeps every other gate advisory.
1391    #[test]
1392    fn a_failed_type_aware_completeness_gate_fails_the_health_print() {
1393        let mut result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1394        result.type_aware_meta = Some(incomplete_required_type_aware_meta());
1395        assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1396        assert_eq!(gate_exit(&result, None, None, true), ExitCode::from(1));
1397
1398        let options = HealthPrintOptions {
1399            quiet: true,
1400            explain: false,
1401            gates: HealthGateOptions::default(),
1402            baseline_path: None,
1403            summary: false,
1404            summary_heading: true,
1405            show_explain_tip: true,
1406            type_aware_scope: None,
1407            skip_score_and_trend: false,
1408            css_requested: false,
1409            json_style: crate::json_style::JsonStyle::Compact,
1410            exit_reason: true,
1411        };
1412        let line = crate::report::gate_outcome_text::exit_reason_line(
1413            health_gate_outcomes(&result, options).as_ref(),
1414            1,
1415            false,
1416            &[],
1417        );
1418        assert_eq!(
1419            line.as_deref(),
1420            Some("Exit code 1: gate type-aware-require failed.")
1421        );
1422    }
1423
1424    #[test]
1425    fn the_envelope_states_a_failed_type_aware_completeness_gate() {
1426        let mut result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1427        result.type_aware_meta = Some(incomplete_required_type_aware_meta());
1428        let options = HealthPrintOptions {
1429            quiet: true,
1430            explain: false,
1431            gates: HealthGateOptions::default(),
1432            baseline_path: None,
1433            summary: false,
1434            summary_heading: true,
1435            show_explain_tip: true,
1436            type_aware_scope: None,
1437            skip_score_and_trend: false,
1438            css_requested: false,
1439            json_style: crate::json_style::JsonStyle::Compact,
1440            exit_reason: true,
1441        };
1442        let gates = serde_json::to_value(health_gate_outcomes(&result, options))
1443            .expect("gate outcomes serialize");
1444        assert_eq!(
1445            gates["type-aware-require"],
1446            serde_json::json!({ "status": "fail", "enforced": true }),
1447            "the run exits 1 on this gate, so the envelope must state it: {gates}"
1448        );
1449    }
1450}