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