1pub mod coverage;
12
13pub use fallow_engine::health_scoring as scoring;
16
17use std::process::ExitCode;
18use std::time::Instant;
19
20use colored::Colorize;
21use fallow_config::OutputFormat;
22use fallow_engine::{
23 HealthExecutionOptions, HealthGateOptions, HealthGroupResolver, HealthPipelineInputs,
24 HealthScopeInputs, HealthSeams, HealthSharedParseData, HealthSort, RuntimeCoverageSeamInput,
25 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
55impl HealthGroupResolver for OwnershipResolver {
56 fn mode_label(&self) -> &'static str {
57 OwnershipResolver::mode_label(self)
58 }
59
60 fn resolve_with_rule(&self, rel_path: &std::path::Path) -> (String, Option<String>) {
61 OwnershipResolver::resolve_with_rule(self, rel_path)
62 }
63
64 fn section_owners_of(&self, rel_path: &std::path::Path) -> Option<&[String]> {
65 OwnershipResolver::section_owners_of(self, rel_path)
66 }
67}
68
69fn health_diff_index<'a>(opts: &HealthOptions<'a>) -> Option<&'a fallow_output::DiffIndex> {
72 match opts.diff_index {
73 Some(index) => Some(index),
74 None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(),
75 None => None,
76 }
77}
78
79fn build_health_group_resolver(
81 opts: &HealthOptions<'_>,
82 config: &fallow_config::ResolvedConfig,
83) -> Result<Option<OwnershipResolver>, ExitCode> {
84 crate::runtime_support::build_ownership_resolver_for_mode(
85 opts.group_by,
86 opts.root,
87 config.codeowners.as_deref(),
88 opts.output,
89 )
90}
91
92fn record_health_telemetry(report: &fallow_output::HealthReport, coverage_gaps_has_findings: bool) {
96 if coverage_gaps_has_findings && report.findings.is_empty() {
97 crate::telemetry::note_findings_present(true);
98 } else {
99 crate::telemetry::note_result_count(report.findings.len());
100 }
101 crate::telemetry::note_analysis_scale(
102 Some(report.summary.files_analyzed),
103 Some(report.summary.functions_analyzed),
104 );
105}
106
107fn health_seams<'a>() -> HealthSeams<'a> {
110 HealthSeams {
111 runtime_coverage_analyzer: &runtime_coverage_seam,
112 note_graph_structure: &|module_count, edge_count| {
113 crate::telemetry::note_graph_structure_counts(module_count, edge_count);
114 },
115 }
116}
117
118#[expect(
122 clippy::needless_pass_by_value,
123 reason = "by-value input matches the engine RuntimeCoverageAnalyzer seam signature"
124)]
125fn runtime_coverage_seam(
126 options: &fallow_engine::RuntimeCoverageOptions,
127 input: RuntimeCoverageSeamInput<'_>,
128) -> Result<fallow_output::RuntimeCoverageReport, ExitCode> {
129 coverage::analyze(
130 options,
131 &coverage::RuntimeCoverageAnalysisInput {
132 root: input.root,
133 modules: input.modules,
134 analysis_output: input.analysis_output,
135 istanbul_coverage: input.istanbul_coverage,
136 file_paths: input.file_paths,
137 ignore_set: input.ignore_set,
138 changed_files: input.changed_files,
139 ws_roots: input.ws_roots,
140 top: input.top,
141 codeowners_path: input.codeowners_path,
142 quiet: input.quiet,
143 output: input.output,
144 },
145 )
146}
147
148fn build_health_scope_inputs<'a>(
151 opts: &HealthOptions<'a>,
152 config: &fallow_config::ResolvedConfig,
153) -> Result<HealthScopeInputs<'a, OwnershipResolver>, ExitCode> {
154 let changed_files = opts
155 .changed_since
156 .and_then(|git_ref| get_changed_files(opts.root, git_ref));
157 let diff_index = health_diff_index(opts);
158 let ws_roots = resolve_workspace_scope(
159 opts.root,
160 opts.workspace,
161 opts.changed_workspaces,
162 opts.output,
163 )?;
164 let group_resolver = build_health_group_resolver(opts, config)?;
165 Ok(HealthScopeInputs {
166 changed_files,
167 diff_index,
168 ws_roots,
169 group_resolver,
170 })
171}
172
173fn load_health_config(
176 opts: &HealthOptions<'_>,
177) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
178 fallow_engine::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
179 .map_err(|e| emit_error(&e, 2, opts.output))?;
180 validate_health_churn_file(opts)?;
181 let t = Instant::now();
182 let config = crate::load_config_for_analysis(
183 opts.root,
184 opts.config_path,
185 crate::ConfigLoadOptions {
186 output: opts.output,
187 no_cache: opts.no_cache,
188 threads: opts.threads,
189 production_override: opts
190 .production_override
191 .or_else(|| opts.production.then_some(true)),
192 quiet: opts.quiet,
193 },
194 fallow_config::ProductionAnalysis::Health,
195 )?;
196 let config_ms = t.elapsed().as_secs_f64() * 1000.0;
197 Ok((config, config_ms))
198}
199
200pub fn execute_health_with_shared_parse(
204 opts: &HealthOptions<'_>,
205 shared: HealthSharedParseData,
206) -> Result<HealthResult, ExitCode> {
207 let (config, config_ms) = load_health_config(opts)?;
208 let scope_inputs = build_health_scope_inputs(opts, &config)?;
209 let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
210 let seams = health_seams();
211 let result = execute_health_inner(
212 opts,
213 HealthPipelineInputs {
214 config,
215 files: shared.files,
216 modules: shared.modules,
217 config_ms,
218 discover_ms: 0.0,
219 parse_ms: 0.0,
220 parse_cpu_ms: 0.0,
221 shared_parse: true,
222 pre_computed_analysis: shared.analysis_output,
223 workspace_diagnostics,
224 },
225 scope_inputs,
226 &seams,
227 )?;
228 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
229 Ok(result)
230}
231
232pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
233 let (config, config_ms) = load_health_config(opts)?;
234
235 let t = Instant::now();
236 let session = fallow_engine::AnalysisSession::from_resolved_config(config);
237 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
238 let session = session.into_parsed_parts(true);
239 let config = session.config;
240 let files = session.files;
241 let modules = session.modules;
242 let workspace_diagnostics = session.workspace_diagnostics;
243 let parse_ms = session.parse_ms;
244 let parse_cpu_ms = session.parse_cpu_ms;
245
246 let scope_inputs = build_health_scope_inputs(opts, &config)?;
247 let seams = health_seams();
248 let result = execute_health_inner(
249 opts,
250 HealthPipelineInputs {
251 config,
252 files,
253 modules,
254 config_ms,
255 discover_ms,
256 parse_ms,
257 parse_cpu_ms,
258 shared_parse: false,
259 pre_computed_analysis: None,
260 workspace_diagnostics,
261 },
262 scope_inputs,
263 &seams,
264 )?;
265 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
266 Ok(result)
267}
268
269pub fn run_health(opts: &HealthOptions<'_>) -> ExitCode {
270 let result = match execute_health(opts) {
271 Ok(r) => r,
272 Err(code) => return code,
273 };
274 if let Some(ref timings) = result.timings {
275 report::print_health_performance(timings, opts.output);
276 }
277 print_health_result(
278 &result,
279 HealthPrintOptions {
280 quiet: opts.quiet,
281 explain: opts.explain,
282 gates: opts.gates,
283 summary: opts.summary,
284 summary_heading: true,
285 show_explain_tip: true,
286 skip_score_and_trend: false,
287 css_requested: opts.css,
288 },
289 )
290}
291
292pub type HealthResult = fallow_engine::HealthAnalysisResult<crate::report::OwnershipResolver>;
294
295#[derive(Clone, Copy)]
315pub struct HealthPrintOptions {
316 pub quiet: bool,
317 pub explain: bool,
318 pub gates: HealthGateOptions,
319 pub summary: bool,
320 pub summary_heading: bool,
321 pub show_explain_tip: bool,
322 pub skip_score_and_trend: bool,
323 pub css_requested: bool,
327}
328
329pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions) -> ExitCode {
330 let ctx = health_report_context(result, options);
331 let report_code = report::print_health_report(
332 &result.report,
333 result.grouping.as_ref(),
334 result.group_resolver.as_ref(),
335 &ctx,
336 result.config.output,
337 );
338 if report_code != ExitCode::SUCCESS {
339 return report_code;
340 }
341
342 if options.gates.report_only {
343 return ExitCode::SUCCESS;
344 }
345
346 if health_exit_gate_failed(result, options) {
347 return ExitCode::from(1);
348 }
349 if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
350 return ExitCode::from(1);
351 }
352 maybe_print_score_gate_note(result, options);
353
354 ExitCode::SUCCESS
355}
356
357fn health_report_context(
358 result: &HealthResult,
359 options: HealthPrintOptions,
360) -> report::ReportContext<'_> {
361 report::ReportContext {
362 root: &result.config.root,
363 rules: &result.config.rules,
364 elapsed: result.elapsed,
365 quiet: options.quiet,
366 explain: options.explain,
367 group_by: None,
368 top: None,
369 summary: options.summary,
370 summary_heading: options.summary_heading,
371 show_explain_tip: options.show_explain_tip,
372 baseline_matched: None,
373 config_fixable: false,
374 skip_score_and_trend: options.skip_score_and_trend,
375 css_requested: options.css_requested,
376 }
377}
378
379fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
380 score_gate_failed(result, options)
381 || findings_gate_failed(result, options)
382 || has_failing_runtime_coverage(result)
383}
384
385fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
386 let Some(threshold) = options.gates.min_score else {
387 return false;
388 };
389 let Some(ref hs) = result.report.health_score else {
390 return false;
391 };
392 if hs.score >= threshold {
393 return false;
394 }
395
396 if !options.quiet {
397 eprintln!(
398 "Health score {:.1} ({}) is below minimum threshold {:.0}",
399 hs.score, hs.grade, threshold
400 );
401 }
402 true
403}
404
405fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
406 if let Some(min_sev) = options.gates.min_severity {
407 result.report.findings.iter().any(|f| f.severity >= min_sev)
408 } else if options.gates.min_score.is_none() {
409 !result.report.findings.is_empty()
410 } else {
411 false
412 }
413}
414
415fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
416 result
417 .report
418 .runtime_coverage
419 .as_ref()
420 .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
421}
422
423fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
424 matches!(
425 finding.verdict,
426 fallow_output::RuntimeCoverageVerdict::SafeToDelete
427 | fallow_output::RuntimeCoverageVerdict::ReviewRequired
428 | fallow_output::RuntimeCoverageVerdict::LowTraffic
429 )
430}
431
432fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions) {
433 if options.gates.min_score.is_none()
434 || options.gates.min_severity.is_some()
435 || options.quiet
436 || result.report.findings.is_empty()
437 || !matches!(result.config.output, OutputFormat::Human)
438 {
439 return;
440 }
441
442 {
443 eprintln!(
444 "{}",
445 "Findings above are informational: --min-score gates on the score, not on findings."
446 .dimmed()
447 );
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use fallow_config::{FallowConfig, OutputFormat};
455 use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
456 use std::path::PathBuf;
457 use std::time::Duration;
458
459 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
460 ComplexityViolation {
461 path: PathBuf::from("/project/src/a.ts"),
462 name: name.to_string(),
463 line: 1,
464 col: 0,
465 cyclomatic: match exceeded {
466 ExceededThreshold::Cyclomatic
467 | ExceededThreshold::Both
468 | ExceededThreshold::CyclomaticCrap
469 | ExceededThreshold::All => 25,
470 _ => 8,
471 },
472 cognitive: match exceeded {
473 ExceededThreshold::Cognitive
474 | ExceededThreshold::Both
475 | ExceededThreshold::CognitiveCrap
476 | ExceededThreshold::All => 20,
477 _ => 5,
478 },
479 line_count: 10,
480 param_count: 0,
481 react_hook_count: 0,
482 react_jsx_max_depth: 0,
483 react_prop_count: 0,
484 react_hook_profile: None,
485 exceeded,
486 severity: FindingSeverity::Moderate,
487 crap: exceeded.includes_crap().then_some(30.0),
488 coverage_pct: None,
489 coverage_tier: None,
490 coverage_source: None,
491 inherited_from: None,
492 component_rollup: None,
493 contributions: Vec::new(),
494 effective_thresholds: None,
495 threshold_source: None,
496 }
497 }
498
499 fn test_resolved_config() -> fallow_config::ResolvedConfig {
500 FallowConfig::default().resolve(
501 PathBuf::from("/project"),
502 OutputFormat::Json,
503 1,
504 true,
505 true,
506 None,
507 )
508 }
509
510 fn fx_summary(
511 tracked: usize,
512 hit: usize,
513 unhit: usize,
514 untracked: usize,
515 ) -> fallow_output::RuntimeCoverageSummary {
516 #[expect(
517 clippy::cast_precision_loss,
518 reason = "test fixture totals are tiny, f64 precision is fine"
519 )]
520 let coverage_percent = if tracked == 0 {
521 0.0
522 } else {
523 (hit as f64 / tracked as f64) * 100.0
524 };
525 fallow_output::RuntimeCoverageSummary {
526 data_source: fallow_output::RuntimeCoverageDataSource::Local,
527 last_received_at: None,
528 functions_tracked: tracked,
529 functions_hit: hit,
530 functions_unhit: unhit,
531 functions_untracked: untracked,
532 coverage_percent,
533 trace_count: 512,
534 period_days: 7,
535 deployments_seen: 2,
536 capture_quality: None,
537 }
538 }
539
540 fn fx_evidence(
541 static_status: &str,
542 test_coverage: &str,
543 v8_tracking: &str,
544 ) -> fallow_output::RuntimeCoverageEvidence {
545 fallow_output::RuntimeCoverageEvidence {
546 static_status: static_status.to_owned(),
547 test_coverage: test_coverage.to_owned(),
548 v8_tracking: v8_tracking.to_owned(),
549 untracked_reason: None,
550 observation_days: 7,
551 deployments_observed: 2,
552 }
553 }
554
555 fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
556 fallow_output::HealthScore {
557 formula_version: 2,
558 score,
559 grade,
560 penalties: fallow_output::HealthScorePenalties {
561 dead_files: None,
562 dead_exports: None,
563 complexity: 0.0,
564 p90_complexity: 0.0,
565 maintainability: None,
566 hotspots: None,
567 unused_deps: None,
568 circular_deps: None,
569 unit_size: None,
570 coupling: None,
571 duplication: None,
572 prop_drilling: None,
573 },
574 }
575 }
576
577 fn fx_gate_result(
578 findings: Vec<fallow_output::HealthFinding>,
579 score: Option<fallow_output::HealthScore>,
580 ) -> HealthResult {
581 HealthResult {
582 report: fallow_output::HealthReport {
583 findings,
584 health_score: score,
585 ..fallow_output::HealthReport::default()
586 },
587 grouping: None,
588 group_resolver: None,
589 config: test_resolved_config(),
590 workspace_diagnostics: Vec::new(),
591 elapsed: Duration::default(),
592 timings: None,
593 coverage_gaps_has_findings: false,
594 should_fail_on_coverage_gaps: false,
595 }
596 }
597
598 fn moderate_finding() -> fallow_output::HealthFinding {
599 make_finding("moderate", ExceededThreshold::Cyclomatic).into()
600 }
601
602 fn critical_finding() -> fallow_output::HealthFinding {
603 let mut v = make_finding("critical", ExceededThreshold::All);
604 v.severity = FindingSeverity::Critical;
605 v.into()
606 }
607
608 fn gate_exit(
610 result: &HealthResult,
611 min_score: Option<f64>,
612 min_severity: Option<FindingSeverity>,
613 report_only: bool,
614 ) -> ExitCode {
615 print_health_result(
616 result,
617 HealthPrintOptions {
618 quiet: true,
619 explain: false,
620 gates: HealthGateOptions {
621 min_score,
622 min_severity,
623 report_only,
624 },
625 summary: false,
626 summary_heading: true,
627 show_explain_tip: true,
628 skip_score_and_trend: false,
629 css_requested: false,
630 },
631 )
632 }
633
634 #[test]
635 fn plain_health_with_findings_fails() {
636 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
637 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
638 }
639
640 #[test]
641 fn plain_health_with_no_findings_succeeds() {
642 let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
643 assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
644 }
645
646 #[test]
647 fn min_score_zero_never_fails_even_with_findings() {
648 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
649 assert_eq!(
650 gate_exit(&result, Some(0.0), None, false),
651 ExitCode::SUCCESS
652 );
653 }
654
655 #[test]
656 fn min_score_passing_demotes_findings_to_informational() {
657 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
658 assert_eq!(
659 gate_exit(&result, Some(80.0), None, false),
660 ExitCode::SUCCESS
661 );
662 }
663
664 #[test]
665 fn min_score_below_threshold_fails() {
666 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
667 assert_eq!(
668 gate_exit(&result, Some(80.0), None, false),
669 ExitCode::from(1)
670 );
671 }
672
673 #[test]
674 fn min_severity_gates_on_severity_independent_of_min_score() {
675 let only_moderate =
676 fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
677 assert_eq!(
678 gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
679 ExitCode::SUCCESS,
680 );
681 let with_critical = fx_gate_result(
682 vec![moderate_finding(), critical_finding()],
683 Some(fx_health_score(87.5, "A")),
684 );
685 assert_eq!(
686 gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
687 ExitCode::from(1),
688 );
689 }
690
691 #[test]
692 fn min_score_and_min_severity_compose_as_or() {
693 let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
694 assert_eq!(
695 gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
696 ExitCode::SUCCESS,
697 );
698 let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
699 assert_eq!(
700 gate_exit(
701 &low_score,
702 Some(80.0),
703 Some(FindingSeverity::Critical),
704 false
705 ),
706 ExitCode::from(1),
707 );
708 let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
709 assert_eq!(
710 gate_exit(
711 &critical,
712 Some(80.0),
713 Some(FindingSeverity::Critical),
714 false
715 ),
716 ExitCode::from(1),
717 );
718 }
719
720 #[test]
721 fn report_only_never_fails_on_findings_or_low_score() {
722 let result = fx_gate_result(
723 vec![moderate_finding(), critical_finding()],
724 Some(fx_health_score(10.0, "F")),
725 );
726 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
727 }
728
729 #[test]
730 fn runtime_coverage_gate_independent_of_min_score() {
731 let result = fx_low_traffic_runtime_result();
732 assert_eq!(
733 gate_exit(&result, Some(0.0), None, false),
734 ExitCode::from(1)
735 );
736 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
737 }
738
739 fn fx_low_traffic_runtime_result() -> HealthResult {
740 HealthResult {
741 report: fallow_output::HealthReport {
742 runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
743 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
744 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
745 signals: Vec::new(),
746 summary: fx_summary(1, 0, 1, 0),
747 findings: vec![fallow_output::RuntimeCoverageFinding {
748 id: "fallow:prod:lowtraffic".to_owned(),
749 stable_id: None,
750 path: PathBuf::from("/project/src/cold.ts"),
751 function: "coldPath".to_owned(),
752 line: 14,
753 verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
754 invocations: Some(1),
755 confidence: fallow_output::RuntimeCoverageConfidence::Low,
756 evidence: fx_evidence("used", "not_covered", "tracked"),
757 actions: vec![],
758 source_hash: None,
759 discriminators: None,
760 }],
761 hot_paths: vec![],
762 blast_radius: vec![],
763 importance: vec![],
764 watermark: None,
765 warnings: vec![],
766 actionable: true,
767 actionability_reason: None,
768 actionability_verdict: None,
769 provenance: fallow_output::RuntimeCoverageProvenance::default(),
770 }),
771 ..fallow_output::HealthReport::default()
772 },
773 grouping: None,
774 group_resolver: None,
775 config: test_resolved_config(),
776 workspace_diagnostics: Vec::new(),
777 elapsed: Duration::default(),
778 timings: None,
779 coverage_gaps_has_findings: false,
780 should_fail_on_coverage_gaps: false,
781 }
782 }
783
784 #[test]
785 fn print_health_result_fails_on_low_traffic_runtime_coverage() {
786 let result = fx_low_traffic_runtime_result();
787
788 assert_eq!(
789 print_health_result(
790 &result,
791 HealthPrintOptions {
792 quiet: true,
793 explain: false,
794 gates: HealthGateOptions::default(),
795 summary: false,
796 summary_heading: true,
797 show_explain_tip: true,
798 skip_score_and_trend: false,
799 css_requested: false,
800 },
801 ),
802 ExitCode::from(1),
803 );
804 }
805}