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