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 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
272 let parts = session.parsed_parts_uncached(true);
273 let pre_computed_analysis =
274 fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
275 .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
276 .transpose()
277 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
278 let config = parts.config;
279 let files = parts.files;
280 let modules = parts.modules;
281 let workspaces = parts.workspaces;
282 let workspace_diagnostics = parts.workspace_diagnostics;
283 let parse_ms = parts.parse_ms;
284 let parse_cpu_ms = parts.parse_cpu_ms;
285
286 let scope_inputs = build_health_scope_inputs(opts, &config)?;
287 let seams = health_seams();
288 let result = execute_health_inner(
289 opts,
290 HealthPipelineInputs {
291 config,
292 files,
293 modules,
294 config_ms,
295 discover_ms,
296 parse_ms,
297 parse_cpu_ms,
298 shared_parse: false,
299 dead_code_results: None,
300 styling_artifacts: None,
301 pre_computed_analysis,
302 pre_computed_duplication: None,
303 workspaces,
304 workspace_diagnostics,
305 },
306 scope_inputs,
307 &seams,
308 )
309 .map_err(|e| health_err_to_exit(e, opts.output))?;
310 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
311 Ok(result)
312}
313
314pub fn run_health(
315 opts: &HealthOptions<'_>,
316 json_style: crate::json_style::JsonStyle,
317 type_aware: &TypeAwareHealthOptions<'_>,
318) -> ExitCode {
319 let mut completeness_failed = false;
320 let (config, config_ms) = match load_health_config(opts) {
321 Ok(config) => config,
322 Err(code) => return code,
323 };
324 let resolved_type_aware = match resolve_type_aware_health_options(type_aware, &config) {
325 Ok(options) => options,
326 Err(message) => return emit_error(&message, 2, opts.output),
327 };
328 let requested = type_aware.requested || (type_aware.unfiltered && resolved_type_aware.enabled);
329 let semantic = if requested {
330 let enabled = resolved_type_aware.enabled;
331 if !enabled {
332 return emit_error(
333 "--type-coupling requires --type-aware or typeAware.enabled in config",
334 2,
335 opts.output,
336 );
337 }
338 let projects = resolved_type_aware.projects;
339 let require = resolved_type_aware.require;
340 let outcome = match fallow_api::analyze_type_coupling(opts.root, &projects, &[]) {
341 Ok(outcome) => outcome,
342 Err(error) => {
343 return emit_error(
344 &format!("Type-aware coupling failed: {error}"),
345 2,
346 opts.output,
347 );
348 }
349 };
350 completeness_failed = require == fallow_config::TypeAwareRequire::Complete
351 && outcome.report.status != fallow_types::semantic::SemanticCompleteness::Complete;
352 Some(outcome)
353 } else {
354 None
355 };
356 let mut execution_opts = opts.clone();
357 if let Some(identity) = semantic
358 .as_ref()
359 .and_then(|outcome| outcome.type_aware.meta.identity.clone())
360 {
361 execution_opts.analysis_identity = identity;
362 }
363 let mut result = match execute_health_with_config(&execution_opts, config, config_ms) {
364 Ok(result) => result,
365 Err(code) => return code,
366 };
367 let required_completeness = result.config.type_aware.require.into();
368 result.type_aware_meta = semantic.map(|outcome| {
369 let mut meta = outcome.type_aware.meta;
370 meta.required_completeness = Some(required_completeness);
371 meta
372 });
373 if let Some(ref timings) = result.timings {
374 report::print_health_performance(timings, opts.output, json_style);
375 }
376 let code = print_health_result(
377 &result,
378 HealthPrintOptions {
379 quiet: opts.quiet,
380 explain: opts.explain,
381 gates: opts.gates,
382 summary: opts.summary,
383 summary_heading: true,
384 show_explain_tip: true,
385 type_aware_scope: None,
386 skip_score_and_trend: false,
387 css_requested: opts.css,
388 json_style,
389 },
390 );
391 if code == ExitCode::SUCCESS && completeness_failed {
392 ExitCode::from(1)
393 } else {
394 code
395 }
396}
397
398pub struct ResolvedTypeAwareHealthOptions {
399 pub enabled: bool,
400 pub projects: Vec<std::path::PathBuf>,
401 pub require: fallow_config::TypeAwareRequire,
402}
403
404pub fn resolve_type_aware_health_options(
405 options: &TypeAwareHealthOptions<'_>,
406 config: &fallow_config::ResolvedConfig,
407) -> Result<ResolvedTypeAwareHealthOptions, String> {
408 let env_enabled = std::env::var("FALLOW_TYPE_AWARE")
409 .ok()
410 .map(|value| match value.trim().to_ascii_lowercase().as_str() {
411 "1" | "true" | "yes" | "on" => Ok(true),
412 "0" | "false" | "no" | "off" => Ok(false),
413 _ => Err(
414 "FALLOW_TYPE_AWARE must be one of true, false, 1, 0, yes, no, on, or off"
415 .to_string(),
416 ),
417 })
418 .transpose()?;
419 let enabled = if options.enabled {
420 true
421 } else {
422 env_enabled.unwrap_or(config.type_aware.enabled)
423 };
424 let projects = if !options.projects.is_empty() {
425 options.projects.to_vec()
426 } else if let Some(value) = std::env::var_os("FALLOW_TYPE_AWARE_PROJECTS") {
427 std::env::split_paths(&value).collect()
428 } else {
429 config
430 .type_aware
431 .projects
432 .iter()
433 .map(std::path::PathBuf::from)
434 .collect()
435 };
436 let require = if let Some(require) = options.require {
437 require
438 } else if let Ok(value) = std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
439 match value.trim().to_ascii_lowercase().as_str() {
440 "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
441 "complete" => fallow_config::TypeAwareRequire::Complete,
442 _ => {
443 return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete".to_string());
444 }
445 }
446 } else {
447 config.type_aware.require
448 };
449 Ok(ResolvedTypeAwareHealthOptions {
450 enabled,
451 projects,
452 require,
453 })
454}
455
456pub type HealthResult =
458 fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
459
460#[derive(Clone, Copy)]
480pub struct HealthPrintOptions {
481 pub quiet: bool,
482 pub explain: bool,
483 pub gates: HealthGateOptions,
484 pub summary: bool,
485 pub summary_heading: bool,
486 pub show_explain_tip: bool,
487 pub type_aware_scope: Option<&'static str>,
488 pub skip_score_and_trend: bool,
489 pub css_requested: bool,
493 pub json_style: crate::json_style::JsonStyle,
494}
495
496pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions) -> ExitCode {
497 let ctx = health_report_context(result, options);
498 let report_code = report::print_health_report(
499 &result.report,
500 result.grouping.as_ref(),
501 result.group_resolver.as_ref(),
502 &ctx,
503 result.config.output,
504 );
505 if report_code != ExitCode::SUCCESS {
506 return report_code;
507 }
508
509 if options.gates.report_only {
510 return ExitCode::SUCCESS;
511 }
512
513 if health_exit_gate_failed(result, options) {
514 return ExitCode::from(1);
515 }
516 if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
517 return ExitCode::from(1);
518 }
519 maybe_print_score_gate_note(result, options);
520
521 ExitCode::SUCCESS
522}
523
524fn health_report_context(
525 result: &HealthResult,
526 options: HealthPrintOptions,
527) -> report::ReportContext<'_> {
528 report::ReportContext {
529 root: &result.config.root,
530 rules: &result.config.rules,
531 elapsed: result.elapsed,
532 quiet: options.quiet,
533 explain: options.explain,
534 type_aware: result.type_aware_meta.as_ref(),
535 type_aware_scope: options.type_aware_scope,
536 group_by: None,
537 top: None,
538 summary: options.summary,
539 summary_heading: options.summary_heading,
540 show_explain_tip: options.show_explain_tip,
541 baseline_matched: None,
542 config_fixable: false,
543 skip_score_and_trend: options.skip_score_and_trend,
544 css_requested: options.css_requested,
545 json_style: options.json_style,
546 }
547}
548
549fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
550 score_gate_failed(result, options)
551 || findings_gate_failed(result, options)
552 || has_failing_runtime_coverage(result)
553}
554
555fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
556 let Some(threshold) = options.gates.min_score else {
557 return false;
558 };
559 let Some(ref hs) = result.report.health_score else {
560 return false;
561 };
562 if hs.score >= threshold {
563 return false;
564 }
565
566 if !options.quiet {
567 eprintln!(
568 "Health score {:.1} ({}) is below minimum threshold {:.0}",
569 hs.score, hs.grade, threshold
570 );
571 }
572 true
573}
574
575fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
576 if let Some(min_sev) = options.gates.min_severity {
577 result.report.findings.iter().any(|f| f.severity >= min_sev)
578 } else if options.gates.min_score.is_none() {
579 !result.report.findings.is_empty()
580 } else {
581 false
582 }
583}
584
585fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
586 result
587 .report
588 .runtime_coverage
589 .as_ref()
590 .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
591}
592
593fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
594 matches!(
595 finding.verdict,
596 fallow_output::RuntimeCoverageVerdict::SafeToDelete
597 | fallow_output::RuntimeCoverageVerdict::ReviewRequired
598 | fallow_output::RuntimeCoverageVerdict::LowTraffic
599 )
600}
601
602fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions) {
603 if options.gates.min_score.is_none()
604 || options.gates.min_severity.is_some()
605 || options.quiet
606 || result.report.findings.is_empty()
607 || !matches!(result.config.output, OutputFormat::Human)
608 {
609 return;
610 }
611
612 {
613 eprintln!(
614 "{}",
615 "Findings above are informational: --min-score gates on the score, not on findings."
616 .dimmed()
617 );
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use fallow_config::{FallowConfig, OutputFormat};
625 use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
626 use std::path::PathBuf;
627 use std::time::Duration;
628
629 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
630 ComplexityViolation {
631 path: PathBuf::from("/project/src/a.ts"),
632 name: name.to_string(),
633 line: 1,
634 col: 0,
635 cyclomatic: match exceeded {
636 ExceededThreshold::Cyclomatic
637 | ExceededThreshold::Both
638 | ExceededThreshold::CyclomaticCrap
639 | ExceededThreshold::All => 25,
640 _ => 8,
641 },
642 cognitive: match exceeded {
643 ExceededThreshold::Cognitive
644 | ExceededThreshold::Both
645 | ExceededThreshold::CognitiveCrap
646 | ExceededThreshold::All => 20,
647 _ => 5,
648 },
649 line_count: 10,
650 param_count: 0,
651 react_hook_count: 0,
652 react_jsx_max_depth: 0,
653 react_prop_count: 0,
654 react_hook_profile: None,
655 exceeded,
656 severity: FindingSeverity::Moderate,
657 crap: exceeded.includes_crap().then_some(30.0),
658 coverage_pct: None,
659 coverage_tier: None,
660 coverage_source: None,
661 inherited_from: None,
662 component_rollup: None,
663 contributions: Vec::new(),
664 effective_thresholds: None,
665 threshold_source: None,
666 }
667 }
668
669 fn test_resolved_config() -> fallow_config::ResolvedConfig {
670 FallowConfig::default().resolve(
671 PathBuf::from("/project"),
672 OutputFormat::Json,
673 1,
674 true,
675 true,
676 None,
677 )
678 }
679
680 fn fx_summary(
681 tracked: usize,
682 hit: usize,
683 unhit: usize,
684 untracked: usize,
685 ) -> fallow_output::RuntimeCoverageSummary {
686 #[expect(
687 clippy::cast_precision_loss,
688 reason = "test fixture totals are tiny, f64 precision is fine"
689 )]
690 let coverage_percent = if tracked == 0 {
691 0.0
692 } else {
693 (hit as f64 / tracked as f64) * 100.0
694 };
695 fallow_output::RuntimeCoverageSummary {
696 data_source: fallow_output::RuntimeCoverageDataSource::Local,
697 last_received_at: None,
698 functions_tracked: tracked,
699 functions_hit: hit,
700 functions_unhit: unhit,
701 functions_untracked: untracked,
702 coverage_percent,
703 trace_count: 512,
704 period_days: 7,
705 deployments_seen: 2,
706 capture_quality: None,
707 }
708 }
709
710 fn fx_evidence(
711 static_status: &str,
712 test_coverage: &str,
713 v8_tracking: &str,
714 ) -> fallow_output::RuntimeCoverageEvidence {
715 fallow_output::RuntimeCoverageEvidence {
716 static_status: static_status.to_owned(),
717 test_coverage: test_coverage.to_owned(),
718 v8_tracking: v8_tracking.to_owned(),
719 untracked_reason: None,
720 observation_days: 7,
721 deployments_observed: 2,
722 }
723 }
724
725 fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
726 fallow_output::HealthScore {
727 formula_version: 2,
728 score,
729 grade,
730 penalties: fallow_output::HealthScorePenalties {
731 dead_files: None,
732 dead_exports: None,
733 complexity: 0.0,
734 p90_complexity: 0.0,
735 maintainability: None,
736 hotspots: None,
737 unused_deps: None,
738 circular_deps: None,
739 unit_size: None,
740 coupling: None,
741 duplication: None,
742 prop_drilling: None,
743 },
744 }
745 }
746
747 fn fx_gate_result(
748 findings: Vec<fallow_output::HealthFinding>,
749 score: Option<fallow_output::HealthScore>,
750 ) -> HealthResult {
751 HealthResult {
752 report: fallow_output::HealthReport {
753 findings,
754 health_score: score,
755 ..fallow_output::HealthReport::default()
756 },
757 grouping: None,
758 group_resolver: None,
759 config: test_resolved_config(),
760 workspace_diagnostics: Vec::new(),
761 elapsed: Duration::default(),
762 timings: None,
763 type_aware_meta: None,
764 coverage_gaps_has_findings: false,
765 should_fail_on_coverage_gaps: false,
766 }
767 }
768
769 fn moderate_finding() -> fallow_output::HealthFinding {
770 make_finding("moderate", ExceededThreshold::Cyclomatic).into()
771 }
772
773 fn critical_finding() -> fallow_output::HealthFinding {
774 let mut v = make_finding("critical", ExceededThreshold::All);
775 v.severity = FindingSeverity::Critical;
776 v.into()
777 }
778
779 fn gate_exit(
781 result: &HealthResult,
782 min_score: Option<f64>,
783 min_severity: Option<FindingSeverity>,
784 report_only: bool,
785 ) -> ExitCode {
786 print_health_result(
787 result,
788 HealthPrintOptions {
789 quiet: true,
790 explain: false,
791 gates: HealthGateOptions {
792 min_score,
793 min_severity,
794 report_only,
795 },
796 summary: false,
797 summary_heading: true,
798 show_explain_tip: true,
799 type_aware_scope: None,
800 skip_score_and_trend: false,
801 css_requested: false,
802 json_style: crate::json_style::JsonStyle::Compact,
803 },
804 )
805 }
806
807 #[test]
808 fn plain_health_with_findings_fails() {
809 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
810 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
811 }
812
813 #[test]
814 fn plain_health_with_no_findings_succeeds() {
815 let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
816 assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
817 }
818
819 #[test]
820 fn min_score_zero_never_fails_even_with_findings() {
821 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
822 assert_eq!(
823 gate_exit(&result, Some(0.0), None, false),
824 ExitCode::SUCCESS
825 );
826 }
827
828 #[test]
829 fn min_score_passing_demotes_findings_to_informational() {
830 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
831 assert_eq!(
832 gate_exit(&result, Some(80.0), None, false),
833 ExitCode::SUCCESS
834 );
835 }
836
837 #[test]
838 fn min_score_below_threshold_fails() {
839 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
840 assert_eq!(
841 gate_exit(&result, Some(80.0), None, false),
842 ExitCode::from(1)
843 );
844 }
845
846 #[test]
847 fn min_severity_gates_on_severity_independent_of_min_score() {
848 let only_moderate =
849 fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
850 assert_eq!(
851 gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
852 ExitCode::SUCCESS,
853 );
854 let with_critical = fx_gate_result(
855 vec![moderate_finding(), critical_finding()],
856 Some(fx_health_score(87.5, "A")),
857 );
858 assert_eq!(
859 gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
860 ExitCode::from(1),
861 );
862 }
863
864 #[test]
865 fn min_score_and_min_severity_compose_as_or() {
866 let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
867 assert_eq!(
868 gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
869 ExitCode::SUCCESS,
870 );
871 let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
872 assert_eq!(
873 gate_exit(
874 &low_score,
875 Some(80.0),
876 Some(FindingSeverity::Critical),
877 false
878 ),
879 ExitCode::from(1),
880 );
881 let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
882 assert_eq!(
883 gate_exit(
884 &critical,
885 Some(80.0),
886 Some(FindingSeverity::Critical),
887 false
888 ),
889 ExitCode::from(1),
890 );
891 }
892
893 #[test]
894 fn report_only_never_fails_on_findings_or_low_score() {
895 let result = fx_gate_result(
896 vec![moderate_finding(), critical_finding()],
897 Some(fx_health_score(10.0, "F")),
898 );
899 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
900 }
901
902 #[test]
903 fn runtime_coverage_gate_independent_of_min_score() {
904 let result = fx_low_traffic_runtime_result();
905 assert_eq!(
906 gate_exit(&result, Some(0.0), None, false),
907 ExitCode::from(1)
908 );
909 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
910 }
911
912 fn fx_low_traffic_runtime_result() -> HealthResult {
913 HealthResult {
914 report: fallow_output::HealthReport {
915 runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
916 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
917 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
918 signals: Vec::new(),
919 summary: fx_summary(1, 0, 1, 0),
920 findings: vec![fallow_output::RuntimeCoverageFinding {
921 id: "fallow:prod:lowtraffic".to_owned(),
922 stable_id: None,
923 path: PathBuf::from("/project/src/cold.ts"),
924 function: "coldPath".to_owned(),
925 line: 14,
926 verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
927 invocations: Some(1),
928 confidence: fallow_output::RuntimeCoverageConfidence::Low,
929 evidence: fx_evidence("used", "not_covered", "tracked"),
930 actions: vec![],
931 source_hash: None,
932 discriminators: None,
933 }],
934 hot_paths: vec![],
935 blast_radius: vec![],
936 importance: vec![],
937 watermark: None,
938 warnings: vec![],
939 actionable: true,
940 actionability_reason: None,
941 actionability_verdict: None,
942 provenance: fallow_output::RuntimeCoverageProvenance::default(),
943 }),
944 ..fallow_output::HealthReport::default()
945 },
946 grouping: None,
947 group_resolver: None,
948 config: test_resolved_config(),
949 workspace_diagnostics: Vec::new(),
950 elapsed: Duration::default(),
951 timings: None,
952 type_aware_meta: None,
953 coverage_gaps_has_findings: false,
954 should_fail_on_coverage_gaps: false,
955 }
956 }
957
958 #[test]
959 fn print_health_result_fails_on_low_traffic_runtime_coverage() {
960 let result = fx_low_traffic_runtime_result();
961
962 assert_eq!(
963 print_health_result(
964 &result,
965 HealthPrintOptions {
966 quiet: true,
967 explain: false,
968 gates: HealthGateOptions::default(),
969 summary: false,
970 summary_heading: true,
971 show_explain_tip: true,
972 type_aware_scope: None,
973 skip_score_and_trend: false,
974 css_requested: false,
975 json_style: crate::json_style::JsonStyle::Compact,
976 },
977 ),
978 ExitCode::from(1),
979 );
980 }
981}