Skip to main content

fallow_cli/health/
mod.rs

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