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::{get_changed_files, 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| get_changed_files(opts.root, git_ref));
168    let diff_index = health_diff_index(opts);
169    let ws_roots = resolve_workspace_scope(
170        opts.root,
171        opts.workspace,
172        opts.changed_workspaces,
173        opts.output,
174    )?;
175    let group_resolver = build_health_group_resolver(opts, config)?;
176    Ok(HealthScopeInputs {
177        changed_files,
178        diff_index,
179        ws_roots,
180        group_resolver,
181    })
182}
183
184/// Translate an engine [`HealthError`] into a CLI exit code at the command
185/// boundary. `Message` is rendered here (the engine no longer prints fatal
186/// errors); `Printed` was already emitted by a lower layer (the runtime-coverage
187/// seam), so its exit code is honored without a second error document.
188fn health_err_to_exit(error: HealthError, output: OutputFormat) -> ExitCode {
189    match error {
190        HealthError::Message { message, exit_code } => emit_error(&message, exit_code, output),
191        HealthError::Printed(code) => ExitCode::from(code),
192    }
193}
194
195/// Load config for a health run, validating coverage-root and churn-file inputs
196/// up front (loud exit 2 on a malformed input).
197pub fn load_health_config(
198    opts: &HealthOptions<'_>,
199) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
200    fallow_engine::health::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
201        .map_err(|e| emit_error(&e, 2, opts.output))?;
202    validate_health_churn_file(opts).map_err(|e| health_err_to_exit(e, opts.output))?;
203    let t = Instant::now();
204    let config = crate::load_config_for_analysis(
205        opts.root,
206        opts.config_path,
207        crate::ConfigLoadOptions {
208            output: opts.output,
209            no_cache: opts.no_cache,
210            threads: opts.threads,
211            production_override: opts
212                .production_override
213                .or_else(|| opts.production.then_some(true)),
214            quiet: opts.quiet,
215            allow_remote_extends: opts.allow_remote_extends,
216        },
217        fallow_config::ProductionAnalysis::Health,
218    )?;
219    let config_ms = t.elapsed().as_secs_f64() * 1000.0;
220    Ok((config, config_ms))
221}
222
223/// Run health analysis using pre-parsed modules from the dead-code pipeline.
224///
225/// Skips file discovery and parsing (saves ~1.9s on 21K-file projects).
226pub fn execute_health_with_shared_parse(
227    opts: &HealthOptions<'_>,
228    shared: HealthSharedParseData,
229) -> Result<HealthResult, ExitCode> {
230    let (config, config_ms) = load_health_config(opts)?;
231    let scope_inputs = build_health_scope_inputs(opts, &config)?;
232    let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
233    let workspaces = shared.workspaces;
234    let seams = health_seams();
235    let result = execute_health_inner(
236        opts,
237        HealthPipelineInputs {
238            config,
239            files: shared.files,
240            modules: shared.modules,
241            config_ms,
242            discover_ms: 0.0,
243            parse_ms: 0.0,
244            parse_cpu_ms: 0.0,
245            shared_parse: true,
246            pre_computed_analysis: shared.analysis_output,
247            dead_code_results: shared.dead_code_results,
248            styling_artifacts: None,
249            pre_computed_duplication: None,
250            workspaces,
251            workspace_diagnostics,
252        },
253        scope_inputs,
254        &seams,
255    )
256    .map_err(|e| health_err_to_exit(e, opts.output))?;
257    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
258    Ok(result)
259}
260
261pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
262    let (config, config_ms) = load_health_config(opts)?;
263    execute_health_with_config(opts, config, config_ms)
264}
265
266pub fn execute_health_with_config(
267    opts: &HealthOptions<'_>,
268    config: fallow_config::ResolvedConfig,
269    config_ms: f64,
270) -> Result<HealthResult, ExitCode> {
271    let seams = health_seams();
272    let result = execute_health_with_config_and_seams(opts, config, config_ms, &seams)?;
273    record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
274    Ok(result)
275}
276
277fn execute_health_with_config_and_seams(
278    opts: &HealthOptions<'_>,
279    config: fallow_config::ResolvedConfig,
280    config_ms: f64,
281    seams: &HealthSeams<'_>,
282) -> Result<HealthResult, ExitCode> {
283    let t = Instant::now();
284    let session = fallow_engine::session::AnalysisSession::from_resolved_config(config)
285        .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
286    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
287    let parts = session.parsed_parts_uncached(true);
288    let pre_computed_analysis =
289        fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
290            .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
291            .transpose()
292            .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
293    let config = parts.config;
294    let files = parts.files;
295    let modules = parts.modules;
296    let workspaces = parts.workspaces;
297    let workspace_diagnostics = if pre_computed_analysis.is_some() {
298        session.current_workspace_diagnostics()
299    } else {
300        parts.workspace_diagnostics
301    };
302    let parse_ms = parts.parse_ms;
303    let parse_cpu_ms = parts.parse_cpu_ms;
304
305    let scope_inputs = build_health_scope_inputs(opts, &config)?;
306    execute_health_inner(
307        opts,
308        HealthPipelineInputs {
309            config,
310            files,
311            modules,
312            config_ms,
313            discover_ms,
314            parse_ms,
315            parse_cpu_ms,
316            shared_parse: false,
317            dead_code_results: None,
318            styling_artifacts: None,
319            pre_computed_analysis,
320            pre_computed_duplication: None,
321            workspaces,
322            workspace_diagnostics,
323        },
324        scope_inputs,
325        seams,
326    )
327    .map_err(|e| health_err_to_exit(e, opts.output))
328}
329
330pub fn benchmark_execute_health_with_response(
331    opts: &HealthOptions<'_>,
332    response_bytes: &[u8],
333    request_len: &std::cell::Cell<usize>,
334) -> Result<HealthResult, ExitCode> {
335    let analyzer = |options: &fallow_engine::health::RuntimeCoverageOptions,
336                    input: RuntimeCoverageSeamInput<'_>| {
337        coverage::analyze_with_transport(
338            options,
339            &coverage::RuntimeCoverageAnalysisInput {
340                root: input.root,
341                modules: input.modules,
342                analysis_output: input.analysis_output,
343                istanbul_coverage: input.istanbul_coverage,
344                file_paths: input.file_paths,
345                ignore_set: input.ignore_set,
346                changed_files: input.changed_files,
347                ws_roots: input.ws_roots,
348                top: input.top,
349                codeowners_path: input.codeowners_path,
350                quiet: input.quiet,
351                output: input.output,
352            },
353            |request, _quiet, output| {
354                let (response, len) =
355                    coverage::in_process_response_transport(request, response_bytes, output)?;
356                request_len.set(len);
357                Ok(response)
358            },
359        )
360    };
361    let seams = HealthSeams {
362        runtime_coverage_analyzer: &analyzer,
363        note_graph_structure: &|_module_count, _edge_count| {},
364    };
365    let (config, config_ms) = load_health_config(opts)?;
366    execute_health_with_config_and_seams(opts, config, config_ms, &seams)
367}
368
369pub fn run_health(
370    opts: &HealthOptions<'_>,
371    json_style: crate::json_style::JsonStyle,
372    type_aware: &TypeAwareHealthOptions<'_>,
373) -> ExitCode {
374    let mut completeness_failed = false;
375    let (config, config_ms) = match load_health_config(opts) {
376        Ok(config) => config,
377        Err(code) => return code,
378    };
379    let resolved_type_aware = match resolve_type_aware_health_options(type_aware, &config) {
380        Ok(options) => options,
381        Err(message) => return emit_error(&message, 2, opts.output),
382    };
383    let requested = type_aware.requested || (type_aware.unfiltered && resolved_type_aware.enabled);
384    let mut degraded_meta = None;
385    let semantic = if requested {
386        let enabled = resolved_type_aware.enabled;
387        if !enabled {
388            return emit_error(
389                "--type-coupling requires --type-aware or typeAware.enabled in config",
390                2,
391                opts.output,
392            );
393        }
394        let projects = resolved_type_aware.projects;
395        let require = resolved_type_aware.require;
396        let outcome = match fallow_api::analyze_type_coupling(opts.root, &projects, &[]) {
397            Ok(outcome) => Some(outcome),
398            Err(error) => {
399                match crate::type_aware_degrade::degrade_or_fail(
400                    &crate::type_aware_degrade::DegradeContext {
401                        root: opts.root,
402                        error: &error.to_string(),
403                        failure_label: "Type-aware coupling failed",
404                        require,
405                        quiet: opts.quiet,
406                        output: opts.output,
407                    },
408                ) {
409                    Ok(meta) => degraded_meta = Some(meta),
410                    Err(code) => return code,
411                }
412                None
413            }
414        };
415        completeness_failed = outcome.as_ref().is_some_and(|outcome| {
416            require == fallow_config::TypeAwareRequire::Complete
417                && outcome.report.status != fallow_types::semantic::SemanticCompleteness::Complete
418        });
419        outcome
420    } else {
421        None
422    };
423    let mut execution_opts = opts.clone();
424    if let Some(identity) = semantic
425        .as_ref()
426        .and_then(|outcome| outcome.type_aware.meta.identity.clone())
427    {
428        execution_opts.analysis_identity = identity;
429    }
430    let mut result = match execute_health_with_config(&execution_opts, config, config_ms) {
431        Ok(result) => result,
432        Err(code) => return code,
433    };
434    let required_completeness = result.config.type_aware.require.into();
435    result.type_aware_meta = semantic
436        .map(|outcome| {
437            let mut meta = outcome.type_aware.meta;
438            meta.required_completeness = Some(required_completeness);
439            meta
440        })
441        .or(degraded_meta);
442    if let Some(ref timings) = result.timings {
443        report::print_health_performance(timings, opts.output, json_style);
444    }
445    let code = print_health_result(
446        &result,
447        HealthPrintOptions {
448            quiet: opts.quiet,
449            explain: opts.explain,
450            gates: opts.gates,
451            summary: opts.summary,
452            summary_heading: true,
453            show_explain_tip: true,
454            type_aware_scope: None,
455            skip_score_and_trend: false,
456            css_requested: opts.css,
457            json_style,
458        },
459    );
460    if code == ExitCode::SUCCESS && completeness_failed {
461        ExitCode::from(1)
462    } else {
463        code
464    }
465}
466
467pub struct ResolvedTypeAwareHealthOptions {
468    pub enabled: bool,
469    pub projects: Vec<std::path::PathBuf>,
470    pub require: fallow_config::TypeAwareRequire,
471}
472
473pub fn resolve_type_aware_health_options(
474    options: &TypeAwareHealthOptions<'_>,
475    config: &fallow_config::ResolvedConfig,
476) -> Result<ResolvedTypeAwareHealthOptions, String> {
477    let env_enabled = std::env::var("FALLOW_TYPE_AWARE")
478        .ok()
479        .map(|value| match value.trim().to_ascii_lowercase().as_str() {
480            "1" | "true" | "yes" | "on" => Ok(true),
481            "0" | "false" | "no" | "off" => Ok(false),
482            _ => Err(
483                "FALLOW_TYPE_AWARE must be one of true, false, 1, 0, yes, no, on, or off"
484                    .to_string(),
485            ),
486        })
487        .transpose()?;
488    let enabled = options
489        .enabled
490        .or(env_enabled)
491        .unwrap_or(config.type_aware.enabled);
492    let projects = if !options.projects.is_empty() {
493        options.projects.to_vec()
494    } else if let Some(value) = std::env::var_os("FALLOW_TYPE_AWARE_PROJECTS") {
495        std::env::split_paths(&value).collect()
496    } else {
497        config
498            .type_aware
499            .projects
500            .iter()
501            .map(std::path::PathBuf::from)
502            .collect()
503    };
504    let require = if let Some(require) = options.require {
505        require
506    } else if let Ok(value) = std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
507        match value.trim().to_ascii_lowercase().as_str() {
508            "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
509            "complete" => fallow_config::TypeAwareRequire::Complete,
510            _ => {
511                return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete".to_string());
512            }
513        }
514    } else {
515        config.type_aware.require
516    };
517    Ok(ResolvedTypeAwareHealthOptions {
518        enabled,
519        projects,
520        require,
521    })
522}
523
524/// Result of executing health analysis without printing.
525pub type HealthResult =
526    fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
527
528/// Print health results and return appropriate exit code.
529///
530/// When called from combined mode (`fallow --score` / `fallow --trend`),
531/// `skip_score_and_trend` MUST be `true`: the orientation header already
532/// renders both blocks and rendering them a second time here would duplicate
533/// the lines. Standalone `fallow health` invocations pass `false`.
534///
535/// Exit-code gating (when `report_only` is `false`): the score gate
536/// (`--min-score`), the findings gate (`--min-severity`, or any finding when
537/// no gate flag is set), the runtime-coverage gate, and the coverage-gap gate
538/// are OR-combined. `report_only` short-circuits all of them to
539/// `ExitCode::SUCCESS` after rendering. Combined and audit callers pass
540/// `report_only: false` (they own their own gate semantics).
541///
542/// Callers that pass `min_score: Some(_)` must ensure
543/// `result.report.health_score` is `Some` (the CLI guarantees this because
544/// `--min-score` implies `--score`). If the score is missing the score gate
545/// cannot evaluate, so a direct API caller that requests a score gate without
546/// computing the score would get a permissive `ExitCode::SUCCESS`.
547#[derive(Clone, Copy)]
548pub struct HealthPrintOptions {
549    pub quiet: bool,
550    pub explain: bool,
551    pub gates: HealthGateOptions,
552    pub summary: bool,
553    pub summary_heading: bool,
554    pub show_explain_tip: bool,
555    pub type_aware_scope: Option<&'static str>,
556    pub skip_score_and_trend: bool,
557    /// Whether `--css` was requested. Forwarded to the human renderer so an empty
558    /// CSS result (no import-reachable stylesheet) is explained rather than
559    /// silently omitted. Defaults `false` for callers that do not request CSS.
560    pub css_requested: bool,
561    pub json_style: crate::json_style::JsonStyle,
562}
563
564pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions) -> ExitCode {
565    let ctx = health_report_context(result, options);
566    let report_code = report::print_health_report(
567        &result.report,
568        result.grouping.as_ref(),
569        result.group_resolver.as_ref(),
570        &ctx,
571        result.config.output,
572    );
573    if report_code != ExitCode::SUCCESS {
574        return report_code;
575    }
576
577    if options.gates.report_only {
578        return ExitCode::SUCCESS;
579    }
580
581    if health_exit_gate_failed(result, options) {
582        return ExitCode::from(1);
583    }
584    if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
585        return ExitCode::from(1);
586    }
587    maybe_print_score_gate_note(result, options);
588
589    ExitCode::SUCCESS
590}
591
592fn health_report_context(
593    result: &HealthResult,
594    options: HealthPrintOptions,
595) -> report::ReportContext<'_> {
596    report::ReportContext {
597        root: &result.config.root,
598        rules: &result.config.rules,
599        workspace_diagnostics: &result.workspace_diagnostics,
600        elapsed: result.elapsed,
601        quiet: options.quiet,
602        explain: options.explain,
603        type_aware: result.type_aware_meta.as_ref(),
604        type_aware_scope: options.type_aware_scope,
605        group_by: None,
606        top: None,
607        summary: options.summary,
608        summary_heading: options.summary_heading,
609        show_explain_tip: options.show_explain_tip,
610        baseline_matched: None,
611        config_fixable: false,
612        skip_score_and_trend: options.skip_score_and_trend,
613        css_requested: options.css_requested,
614        json_style: options.json_style,
615        include_fragments: true,
616    }
617}
618
619fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
620    score_gate_failed(result, options)
621        || findings_gate_failed(result, options)
622        || has_failing_runtime_coverage(result)
623}
624
625fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
626    let Some(threshold) = options.gates.min_score else {
627        return false;
628    };
629    let Some(ref hs) = result.report.health_score else {
630        return false;
631    };
632    if hs.score >= threshold {
633        return false;
634    }
635
636    if !options.quiet {
637        eprintln!(
638            "Health score {:.1} ({}) is below minimum threshold {:.0}",
639            hs.score, hs.grade, threshold
640        );
641    }
642    true
643}
644
645fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
646    if let Some(min_sev) = options.gates.min_severity {
647        result.report.findings.iter().any(|f| f.severity >= min_sev)
648    } else if options.gates.min_score.is_none() {
649        !result.report.findings.is_empty()
650    } else {
651        false
652    }
653}
654
655fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
656    result
657        .report
658        .runtime_coverage
659        .as_ref()
660        .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
661}
662
663fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
664    matches!(
665        finding.verdict,
666        fallow_output::RuntimeCoverageVerdict::SafeToDelete
667            | fallow_output::RuntimeCoverageVerdict::ReviewRequired
668            | fallow_output::RuntimeCoverageVerdict::LowTraffic
669    )
670}
671
672fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions) {
673    if options.gates.min_score.is_none()
674        || options.gates.min_severity.is_some()
675        || options.quiet
676        || result.report.findings.is_empty()
677        || !matches!(result.config.output, OutputFormat::Human)
678    {
679        return;
680    }
681
682    {
683        eprintln!(
684            "{}",
685            "Findings above are informational: --min-score gates on the score, not on findings."
686                .dimmed()
687        );
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use fallow_config::{FallowConfig, OutputFormat};
695    use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
696    use std::path::PathBuf;
697    use std::time::Duration;
698
699    fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
700        ComplexityViolation {
701            path: PathBuf::from("/project/src/a.ts"),
702            name: name.to_string(),
703            line: 1,
704            col: 0,
705            cyclomatic: match exceeded {
706                ExceededThreshold::Cyclomatic
707                | ExceededThreshold::Both
708                | ExceededThreshold::CyclomaticCrap
709                | ExceededThreshold::All => 25,
710                _ => 8,
711            },
712            cognitive: match exceeded {
713                ExceededThreshold::Cognitive
714                | ExceededThreshold::Both
715                | ExceededThreshold::CognitiveCrap
716                | ExceededThreshold::All => 20,
717                _ => 5,
718            },
719            line_count: 10,
720            param_count: 0,
721            react_hook_count: 0,
722            react_jsx_max_depth: 0,
723            react_prop_count: 0,
724            react_hook_profile: None,
725            exceeded,
726            severity: FindingSeverity::Moderate,
727            crap: exceeded.includes_crap().then_some(30.0),
728            coverage_pct: None,
729            coverage_tier: None,
730            coverage_source: None,
731            inherited_from: None,
732            component_rollup: None,
733            contributions: Vec::new(),
734            effective_thresholds: None,
735            threshold_source: None,
736        }
737    }
738
739    fn test_resolved_config() -> fallow_config::ResolvedConfig {
740        FallowConfig::default().resolve(
741            PathBuf::from("/project"),
742            OutputFormat::Json,
743            1,
744            true,
745            true,
746            None,
747        )
748    }
749
750    fn fx_summary(
751        tracked: usize,
752        hit: usize,
753        unhit: usize,
754        untracked: usize,
755    ) -> fallow_output::RuntimeCoverageSummary {
756        #[expect(
757            clippy::cast_precision_loss,
758            reason = "test fixture totals are tiny, f64 precision is fine"
759        )]
760        let coverage_percent = if tracked == 0 {
761            0.0
762        } else {
763            (hit as f64 / tracked as f64) * 100.0
764        };
765        fallow_output::RuntimeCoverageSummary {
766            data_source: fallow_output::RuntimeCoverageDataSource::Local,
767            last_received_at: None,
768            functions_tracked: tracked,
769            functions_hit: hit,
770            functions_unhit: unhit,
771            functions_untracked: untracked,
772            coverage_percent,
773            trace_count: 512,
774            period_days: 7,
775            deployments_seen: 2,
776            capture_quality: None,
777        }
778    }
779
780    fn fx_evidence(
781        static_status: &str,
782        test_coverage: &str,
783        v8_tracking: &str,
784    ) -> fallow_output::RuntimeCoverageEvidence {
785        fallow_output::RuntimeCoverageEvidence {
786            static_status: static_status.to_owned(),
787            test_coverage: test_coverage.to_owned(),
788            test_only_reference: None,
789            v8_tracking: v8_tracking.to_owned(),
790            untracked_reason: None,
791            observation_days: 7,
792            deployments_observed: 2,
793        }
794    }
795
796    fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
797        fallow_output::HealthScore {
798            formula_version: 2,
799            score,
800            grade,
801            penalties: fallow_output::HealthScorePenalties {
802                dead_files: None,
803                dead_exports: None,
804                complexity: 0.0,
805                p90_complexity: 0.0,
806                maintainability: None,
807                hotspots: None,
808                unused_deps: None,
809                circular_deps: None,
810                unit_size: None,
811                coupling: None,
812                duplication: None,
813                prop_drilling: None,
814            },
815        }
816    }
817
818    fn fx_gate_result(
819        findings: Vec<fallow_output::HealthFinding>,
820        score: Option<fallow_output::HealthScore>,
821    ) -> HealthResult {
822        HealthResult {
823            branching_by_file: fallow_engine::health::BranchingByFile::default(),
824            report: fallow_output::HealthReport {
825                findings,
826                health_score: score,
827                ..fallow_output::HealthReport::default()
828            },
829            grouping: None,
830            group_resolver: None,
831            config: test_resolved_config(),
832            workspace_diagnostics: Vec::new(),
833            elapsed: Duration::default(),
834            timings: None,
835            type_aware_meta: None,
836            coverage_gaps_has_findings: false,
837            should_fail_on_coverage_gaps: false,
838        }
839    }
840
841    fn moderate_finding() -> fallow_output::HealthFinding {
842        make_finding("moderate", ExceededThreshold::Cyclomatic).into()
843    }
844
845    fn critical_finding() -> fallow_output::HealthFinding {
846        let mut v = make_finding("critical", ExceededThreshold::All);
847        v.severity = FindingSeverity::Critical;
848        v.into()
849    }
850
851    /// Helper: run the gate with the given flags, quiet, no report-only.
852    fn gate_exit(
853        result: &HealthResult,
854        min_score: Option<f64>,
855        min_severity: Option<FindingSeverity>,
856        report_only: bool,
857    ) -> ExitCode {
858        print_health_result(
859            result,
860            HealthPrintOptions {
861                quiet: true,
862                explain: false,
863                gates: HealthGateOptions {
864                    min_score,
865                    min_severity,
866                    report_only,
867                },
868                summary: false,
869                summary_heading: true,
870                show_explain_tip: true,
871                type_aware_scope: None,
872                skip_score_and_trend: false,
873                css_requested: false,
874                json_style: crate::json_style::JsonStyle::Compact,
875            },
876        )
877    }
878
879    #[test]
880    fn plain_health_with_findings_fails() {
881        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
882        assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
883    }
884
885    #[test]
886    fn plain_health_with_no_findings_succeeds() {
887        let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
888        assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
889    }
890
891    #[test]
892    fn min_score_zero_never_fails_even_with_findings() {
893        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
894        assert_eq!(
895            gate_exit(&result, Some(0.0), None, false),
896            ExitCode::SUCCESS
897        );
898    }
899
900    #[test]
901    fn min_score_passing_demotes_findings_to_informational() {
902        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
903        assert_eq!(
904            gate_exit(&result, Some(80.0), None, false),
905            ExitCode::SUCCESS
906        );
907    }
908
909    #[test]
910    fn min_score_below_threshold_fails() {
911        let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
912        assert_eq!(
913            gate_exit(&result, Some(80.0), None, false),
914            ExitCode::from(1)
915        );
916    }
917
918    #[test]
919    fn min_severity_gates_on_severity_independent_of_min_score() {
920        let only_moderate =
921            fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
922        assert_eq!(
923            gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
924            ExitCode::SUCCESS,
925        );
926        let with_critical = fx_gate_result(
927            vec![moderate_finding(), critical_finding()],
928            Some(fx_health_score(87.5, "A")),
929        );
930        assert_eq!(
931            gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
932            ExitCode::from(1),
933        );
934    }
935
936    #[test]
937    fn min_score_and_min_severity_compose_as_or() {
938        let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
939        assert_eq!(
940            gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
941            ExitCode::SUCCESS,
942        );
943        let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
944        assert_eq!(
945            gate_exit(
946                &low_score,
947                Some(80.0),
948                Some(FindingSeverity::Critical),
949                false
950            ),
951            ExitCode::from(1),
952        );
953        let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
954        assert_eq!(
955            gate_exit(
956                &critical,
957                Some(80.0),
958                Some(FindingSeverity::Critical),
959                false
960            ),
961            ExitCode::from(1),
962        );
963    }
964
965    #[test]
966    fn report_only_never_fails_on_findings_or_low_score() {
967        let result = fx_gate_result(
968            vec![moderate_finding(), critical_finding()],
969            Some(fx_health_score(10.0, "F")),
970        );
971        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
972    }
973
974    #[test]
975    fn runtime_coverage_gate_independent_of_min_score() {
976        let result = fx_low_traffic_runtime_result();
977        assert_eq!(
978            gate_exit(&result, Some(0.0), None, false),
979            ExitCode::from(1)
980        );
981        assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
982    }
983
984    fn fx_low_traffic_runtime_result() -> HealthResult {
985        HealthResult {
986            branching_by_file: fallow_engine::health::BranchingByFile::default(),
987            report: fallow_output::HealthReport {
988                runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
989                    schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
990                    verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
991                    signals: Vec::new(),
992                    summary: fx_summary(1, 0, 1, 0),
993                    findings: vec![fallow_output::RuntimeCoverageFinding {
994                        id: "fallow:prod:lowtraffic".to_owned(),
995                        stable_id: None,
996                        path: PathBuf::from("/project/src/cold.ts"),
997                        function: "coldPath".to_owned(),
998                        line: 14,
999                        verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
1000                        invocations: Some(1),
1001                        confidence: fallow_output::RuntimeCoverageConfidence::Low,
1002                        evidence: fx_evidence("used", "not_covered", "tracked"),
1003                        actions: vec![],
1004                        source_hash: None,
1005                        discriminators: None,
1006                    }],
1007                    hot_paths: vec![],
1008                    blast_radius: vec![],
1009                    importance: vec![],
1010                    watermark: None,
1011                    warnings: vec![],
1012                    actionable: true,
1013                    actionability_reason: None,
1014                    actionability_verdict: None,
1015                    provenance: fallow_output::RuntimeCoverageProvenance::default(),
1016                }),
1017                ..fallow_output::HealthReport::default()
1018            },
1019            grouping: None,
1020            group_resolver: None,
1021            config: test_resolved_config(),
1022            workspace_diagnostics: Vec::new(),
1023            elapsed: Duration::default(),
1024            timings: None,
1025            type_aware_meta: None,
1026            coverage_gaps_has_findings: false,
1027            should_fail_on_coverage_gaps: false,
1028        }
1029    }
1030
1031    #[test]
1032    fn print_health_result_fails_on_low_traffic_runtime_coverage() {
1033        let result = fx_low_traffic_runtime_result();
1034
1035        assert_eq!(
1036            print_health_result(
1037                &result,
1038                HealthPrintOptions {
1039                    quiet: true,
1040                    explain: false,
1041                    gates: HealthGateOptions::default(),
1042                    summary: false,
1043                    summary_heading: true,
1044                    show_explain_tip: true,
1045                    type_aware_scope: None,
1046                    skip_score_and_trend: false,
1047                    css_requested: false,
1048                    json_style: crate::json_style::JsonStyle::Compact,
1049                },
1050            ),
1051            ExitCode::from(1),
1052        );
1053    }
1054}