1pub mod coverage;
12
13pub 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#[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
55pub 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
78fn 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
88fn 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
101fn 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
116fn 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#[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
157fn 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
182fn 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
193pub 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
221pub 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
457pub type HealthResult =
459 fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
460
461#[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 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 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}