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