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