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