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