Skip to main content

fallow_cli/health/
mod.rs

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