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