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 mut ws_roots = resolve_workspace_scope(
170 opts.root,
171 opts.workspace,
172 opts.changed_workspaces,
173 opts.output,
174 )?;
175 if let Some(scope) = opts.scope.as_ref() {
176 ws_roots.get_or_insert_with(Vec::new).push(scope.clone());
177 }
178 let group_resolver = build_health_group_resolver(opts, config)?;
179 Ok(HealthScopeInputs {
180 changed_files,
181 diff_index,
182 ws_roots,
183 group_resolver,
184 })
185}
186
187fn health_err_to_exit(error: HealthError, output: OutputFormat) -> ExitCode {
192 match error {
193 HealthError::Message { message, exit_code } => emit_error(&message, exit_code, output),
194 HealthError::Printed(code) => ExitCode::from(code),
195 }
196}
197
198pub fn load_health_config(
201 opts: &HealthOptions<'_>,
202) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
203 fallow_engine::health::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
204 .map_err(|e| emit_error(&e, 2, opts.output))?;
205 validate_health_churn_file(opts).map_err(|e| health_err_to_exit(e, opts.output))?;
206 let t = Instant::now();
207 let config = crate::load_config_for_analysis(
208 opts.root,
209 opts.config_path,
210 crate::ConfigLoadOptions {
211 output: opts.output,
212 no_cache: opts.no_cache,
213 threads: opts.threads,
214 production_override: opts
215 .production_override
216 .or_else(|| opts.production.then_some(true)),
217 quiet: opts.quiet,
218 allow_remote_extends: opts.allow_remote_extends,
219 },
220 fallow_config::ProductionAnalysis::Health,
221 )?;
222 let config_ms = t.elapsed().as_secs_f64() * 1000.0;
223 Ok((config, config_ms))
224}
225
226pub fn execute_health_with_shared_parse(
230 opts: &HealthOptions<'_>,
231 shared: HealthSharedParseData,
232) -> Result<HealthResult, ExitCode> {
233 let (config, config_ms) = load_health_config(opts)?;
234 let scope_inputs = build_health_scope_inputs(opts, &config)?;
235 let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
236 let workspaces = shared.workspaces;
237 let seams = health_seams();
238 let result = execute_health_inner(
239 opts,
240 HealthPipelineInputs {
241 config,
242 files: shared.files,
243 modules: shared.modules,
244 config_ms,
245 discover_ms: 0.0,
246 parse_ms: 0.0,
247 parse_cpu_ms: 0.0,
248 shared_parse: true,
249 pre_computed_analysis: shared.analysis_output,
250 dead_code_results: shared.dead_code_results,
251 styling_artifacts: None,
252 pre_computed_duplication: None,
253 workspaces,
254 workspace_diagnostics,
255 },
256 scope_inputs,
257 &seams,
258 )
259 .map_err(|e| health_err_to_exit(e, opts.output))?;
260 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
261 Ok(result)
262}
263
264pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
265 let (config, config_ms) = load_health_config(opts)?;
266 execute_health_with_config(opts, config, config_ms)
267}
268
269pub fn execute_health_with_config(
270 opts: &HealthOptions<'_>,
271 config: fallow_config::ResolvedConfig,
272 config_ms: f64,
273) -> Result<HealthResult, ExitCode> {
274 let seams = health_seams();
275 let result = execute_health_with_config_and_seams(opts, config, config_ms, &seams)?;
276 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
277 Ok(result)
278}
279
280fn execute_health_with_config_and_seams(
281 opts: &HealthOptions<'_>,
282 config: fallow_config::ResolvedConfig,
283 config_ms: f64,
284 seams: &HealthSeams<'_>,
285) -> Result<HealthResult, ExitCode> {
286 let t = Instant::now();
287 let session = fallow_engine::session::AnalysisSession::from_resolved_config(config)
288 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
289 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
290 let parts = session.parsed_parts_uncached(true);
291 let pre_computed_analysis =
292 fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
293 .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
294 .transpose()
295 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
296 let config = parts.config;
297 let files = parts.files;
298 let modules = parts.modules;
299 let workspaces = parts.workspaces;
300 let workspace_diagnostics = if pre_computed_analysis.is_some() {
301 session.current_workspace_diagnostics()
302 } else {
303 parts.workspace_diagnostics
304 };
305 let parse_ms = parts.parse_ms;
306 let parse_cpu_ms = parts.parse_cpu_ms;
307
308 let scope_inputs = build_health_scope_inputs(opts, &config)?;
309 execute_health_inner(
310 opts,
311 HealthPipelineInputs {
312 config,
313 files,
314 modules,
315 config_ms,
316 discover_ms,
317 parse_ms,
318 parse_cpu_ms,
319 shared_parse: false,
320 dead_code_results: None,
321 styling_artifacts: None,
322 pre_computed_analysis,
323 pre_computed_duplication: None,
324 workspaces,
325 workspace_diagnostics,
326 },
327 scope_inputs,
328 seams,
329 )
330 .map_err(|e| health_err_to_exit(e, opts.output))
331}
332
333pub fn benchmark_execute_health_with_response(
334 opts: &HealthOptions<'_>,
335 response_bytes: &[u8],
336 request_len: &std::cell::Cell<usize>,
337) -> Result<HealthResult, ExitCode> {
338 let analyzer = |options: &fallow_engine::health::RuntimeCoverageOptions,
339 input: RuntimeCoverageSeamInput<'_>| {
340 coverage::analyze_with_transport(
341 options,
342 &coverage::RuntimeCoverageAnalysisInput {
343 root: input.root,
344 modules: input.modules,
345 analysis_output: input.analysis_output,
346 istanbul_coverage: input.istanbul_coverage,
347 file_paths: input.file_paths,
348 ignore_set: input.ignore_set,
349 changed_files: input.changed_files,
350 ws_roots: input.ws_roots,
351 top: input.top,
352 codeowners_path: input.codeowners_path,
353 quiet: input.quiet,
354 output: input.output,
355 },
356 |request, _quiet, output| {
357 let (response, len) =
358 coverage::in_process_response_transport(request, response_bytes, output)?;
359 request_len.set(len);
360 Ok(response)
361 },
362 )
363 };
364 let seams = HealthSeams {
365 runtime_coverage_analyzer: &analyzer,
366 note_graph_structure: &|_module_count, _edge_count| {},
367 };
368 let (config, config_ms) = load_health_config(opts)?;
369 execute_health_with_config_and_seams(opts, config, config_ms, &seams)
370}
371
372pub fn run_health(
373 opts: &HealthOptions<'_>,
374 json_style: crate::json_style::JsonStyle,
375 type_aware: &TypeAwareHealthOptions<'_>,
376) -> ExitCode {
377 let mut completeness_failed = false;
378 let (config, config_ms) = match load_health_config(opts) {
379 Ok(config) => config,
380 Err(code) => return code,
381 };
382 let resolved_type_aware = match resolve_type_aware_health_options(type_aware, &config) {
383 Ok(options) => options,
384 Err(message) => return emit_error(&message, 2, opts.output),
385 };
386 let requested = type_aware.requested || (type_aware.unfiltered && resolved_type_aware.enabled);
387 let mut degraded_meta = None;
388 let semantic = if requested {
389 let enabled = resolved_type_aware.enabled;
390 if !enabled {
391 return emit_error(
392 "--type-coupling requires --type-aware or typeAware.enabled in config",
393 2,
394 opts.output,
395 );
396 }
397 let projects = resolved_type_aware.projects;
398 let require = resolved_type_aware.require;
399 let outcome = match fallow_api::analyze_type_coupling(opts.root, &projects, &[]) {
400 Ok(outcome) => Some(outcome),
401 Err(error) => {
402 match crate::type_aware_degrade::degrade_or_fail(
403 &crate::type_aware_degrade::DegradeContext {
404 root: opts.root,
405 error: &error.to_string(),
406 failure_label: "Type-aware coupling failed",
407 require,
408 quiet: opts.quiet,
409 output: opts.output,
410 },
411 ) {
412 Ok(meta) => degraded_meta = Some(meta),
413 Err(code) => return code,
414 }
415 None
416 }
417 };
418 completeness_failed = outcome.as_ref().is_some_and(|outcome| {
419 require == fallow_config::TypeAwareRequire::Complete
420 && outcome.report.status != fallow_types::semantic::SemanticCompleteness::Complete
421 });
422 outcome
423 } else {
424 None
425 };
426 let mut execution_opts = opts.clone();
427 if let Some(identity) = semantic
428 .as_ref()
429 .and_then(|outcome| outcome.type_aware.meta.identity.clone())
430 {
431 execution_opts.analysis_identity = identity;
432 }
433 let mut result = match execute_health_with_config(&execution_opts, config, config_ms) {
434 Ok(result) => result,
435 Err(code) => return code,
436 };
437 let required_completeness = result.config.type_aware.require.into();
438 result.type_aware_meta = semantic
439 .map(|outcome| {
440 let mut meta = outcome.type_aware.meta;
441 meta.required_completeness = Some(required_completeness);
442 meta
443 })
444 .or(degraded_meta);
445 if let Some(ref timings) = result.timings {
446 report::print_health_performance(timings, opts.output, json_style);
447 }
448 let code = print_health_result(
449 &result,
450 HealthPrintOptions {
451 quiet: opts.quiet,
452 explain: opts.explain,
453 gates: opts.gates,
454 baseline_path: opts.baseline,
455 summary: opts.summary,
456 summary_heading: true,
457 show_explain_tip: true,
458 type_aware_scope: None,
459 skip_score_and_trend: false,
460 css_requested: opts.css,
461 json_style,
462 },
463 );
464 if code == ExitCode::SUCCESS && completeness_failed {
465 ExitCode::from(1)
466 } else {
467 code
468 }
469}
470
471pub struct ResolvedTypeAwareHealthOptions {
472 pub enabled: bool,
473 pub projects: Vec<std::path::PathBuf>,
474 pub require: fallow_config::TypeAwareRequire,
475}
476
477pub fn resolve_type_aware_health_options(
478 options: &TypeAwareHealthOptions<'_>,
479 config: &fallow_config::ResolvedConfig,
480) -> Result<ResolvedTypeAwareHealthOptions, String> {
481 let env_enabled = std::env::var("FALLOW_TYPE_AWARE")
482 .ok()
483 .map(|value| match value.trim().to_ascii_lowercase().as_str() {
484 "1" | "true" | "yes" | "on" => Ok(true),
485 "0" | "false" | "no" | "off" => Ok(false),
486 _ => Err(
487 "FALLOW_TYPE_AWARE must be one of true, false, 1, 0, yes, no, on, or off"
488 .to_string(),
489 ),
490 })
491 .transpose()?;
492 let enabled = options
493 .enabled
494 .or(env_enabled)
495 .unwrap_or(config.type_aware.enabled);
496 let projects = if !options.projects.is_empty() {
497 options.projects.to_vec()
498 } else if let Some(value) = std::env::var_os("FALLOW_TYPE_AWARE_PROJECTS") {
499 std::env::split_paths(&value).collect()
500 } else {
501 config
502 .type_aware
503 .projects
504 .iter()
505 .map(std::path::PathBuf::from)
506 .collect()
507 };
508 let require = if let Some(require) = options.require {
509 require
510 } else if let Ok(value) = std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
511 match value.trim().to_ascii_lowercase().as_str() {
512 "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
513 "complete" => fallow_config::TypeAwareRequire::Complete,
514 _ => {
515 return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete".to_string());
516 }
517 }
518 } else {
519 config.type_aware.require
520 };
521 Ok(ResolvedTypeAwareHealthOptions {
522 enabled,
523 projects,
524 require,
525 })
526}
527
528pub type HealthResult =
530 fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
531
532#[derive(Clone, Copy)]
553pub struct HealthPrintOptions<'a> {
554 pub quiet: bool,
555 pub explain: bool,
556 pub gates: HealthGateOptions,
557 pub baseline_path: Option<&'a std::path::Path>,
561 pub summary: bool,
562 pub summary_heading: bool,
563 pub show_explain_tip: bool,
564 pub type_aware_scope: Option<&'static str>,
565 pub skip_score_and_trend: bool,
566 pub css_requested: bool,
570 pub json_style: crate::json_style::JsonStyle,
571}
572
573pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions<'_>) -> ExitCode {
574 let ctx = health_report_context(result, options);
575 let report_code = report::print_health_report(
576 &result.report,
577 result.grouping.as_ref(),
578 result.group_resolver.as_ref(),
579 &ctx,
580 result.config.output,
581 );
582 if report_code != ExitCode::SUCCESS {
583 return report_code;
584 }
585
586 if options.gates.report_only {
587 note_stale_baseline_gate_stood_down(result, options);
588 return ExitCode::SUCCESS;
589 }
590
591 if health_exit_gate_failed(result, options) {
592 return ExitCode::from(1);
593 }
594 if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
595 return ExitCode::from(1);
596 }
597 maybe_print_score_gate_note(result, options);
598
599 ExitCode::SUCCESS
600}
601
602fn health_report_context<'a>(
603 result: &'a HealthResult,
604 options: HealthPrintOptions<'a>,
605) -> report::ReportContext<'a> {
606 report::ReportContext {
607 root: &result.config.root,
608 rules: &result.config.rules,
609 workspace_diagnostics: &result.workspace_diagnostics,
610 elapsed: result.elapsed,
611 quiet: options.quiet,
612 explain: options.explain,
613 type_aware: result.type_aware_meta.as_ref(),
614 type_aware_scope: options.type_aware_scope,
615 group_by: None,
616 top: None,
617 summary: options.summary,
618 summary_heading: options.summary_heading,
619 show_explain_tip: options.show_explain_tip,
620 baseline_matched: None,
621 baseline_staleness: None,
622 gate_outcomes: health_gate_outcomes(result, options),
623 config_fixable: false,
624 skip_score_and_trend: options.skip_score_and_trend,
625 css_requested: options.css_requested,
626 json_style: options.json_style,
627 include_fragments: true,
628 }
629}
630
631fn health_gate_outcomes(
649 result: &HealthResult,
650 options: HealthPrintOptions<'_>,
651) -> Option<fallow_output::GateOutcomes> {
652 use fallow_output::{GateName, GateOutcome, GateStatus};
653
654 let enforced = !options.gates.report_only;
655 let mut gates = fallow_output::GateOutcomes::new();
656
657 if let Some(threshold) = options.gates.min_score {
658 gates.insert(
663 GateName::HealthMinScore,
664 result.report.health_score.as_ref().map_or_else(
665 || GateOutcome::new(GateStatus::Skipped, false),
666 |score| {
667 GateOutcome::measured(
668 crate::gates::status_of(score.score < threshold),
669 enforced,
670 score.score,
671 threshold,
672 )
673 },
674 ),
675 );
676 }
677
678 if let Some(min_sev) = options.gates.min_severity {
679 let reached = result
680 .report
681 .findings
682 .iter()
683 .filter(|f| f.severity >= min_sev)
684 .count();
685 gates.insert(
686 GateName::HealthMinSeverity,
687 GateOutcome::counted(
688 crate::gates::status_of(reached > 0),
689 enforced,
690 #[expect(
691 clippy::cast_precision_loss,
692 reason = "a finding count never approaches the f64 integer limit"
693 )]
694 {
695 reached as f64
696 },
697 severity_floor_label(min_sev),
698 ),
699 );
700 }
701
702 if result.should_fail_on_coverage_gaps {
703 gates.insert(
704 GateName::HealthCoverageGaps,
705 GateOutcome::new(
706 crate::gates::status_of(result.coverage_gaps_has_findings),
707 enforced,
708 ),
709 );
710 }
711
712 if result.report.runtime_coverage.is_some() {
716 gates.insert(
717 GateName::HealthRuntimeCoverage,
718 GateOutcome::new(
719 crate::gates::status_of(has_failing_runtime_coverage(result)),
720 enforced,
721 ),
722 );
723 }
724
725 gates.insert_if(
726 GateName::StaleBaseline,
727 crate::gates::stale_baseline_outcome(
728 result.report.summary.baseline_staleness.as_ref(),
729 options.gates.fail_on_stale_baseline && enforced,
730 ),
731 );
732
733 if gates.is_empty() {
734 return None;
735 }
736
737 if options.gates.min_severity.is_none() {
741 gates.insert(
742 GateName::HealthFindings,
743 if options.gates.min_score.is_some() {
744 GateOutcome::new(GateStatus::Skipped, false)
747 } else {
748 GateOutcome::new(
749 crate::gates::status_of(!result.report.findings.is_empty()),
750 enforced,
751 )
752 },
753 );
754 }
755
756 gates.into_option()
757}
758
759const fn severity_floor_label(severity: fallow_output::FindingSeverity) -> &'static str {
761 match severity {
762 fallow_output::FindingSeverity::Moderate => "moderate",
763 fallow_output::FindingSeverity::High => "high",
764 fallow_output::FindingSeverity::Critical => "critical",
765 }
766}
767
768fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
776 let score = score_gate_failed(result, options);
777 let findings = findings_gate_failed(result, options);
778 let runtime_coverage = has_failing_runtime_coverage(result);
779 let stale_baseline = stale_baseline_gate_failed(result, options);
780 score || findings || runtime_coverage || stale_baseline
781}
782
783fn note_stale_baseline_gate_stood_down(result: &HealthResult, options: HealthPrintOptions<'_>) {
789 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
790 return;
791 };
792 if staleness.baseline_entries == 0 {
793 return;
794 }
795 crate::baseline_gate::note_stood_down(
796 options.baseline_path,
797 options.gates.fail_on_stale_baseline,
798 "--report-only never fails a run",
799 );
800}
801
802fn stale_baseline_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
805 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
806 return false;
807 };
808 crate::baseline_gate::gate_failed_from_counts(
809 staleness.baseline_entries,
810 staleness.matched_entries,
811 staleness.change_scoped,
812 options.baseline_path,
813 options.gates.fail_on_stale_baseline,
814 crate::baseline_gate::HEALTH_NOUN,
815 )
816}
817
818fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
819 let Some(threshold) = options.gates.min_score else {
820 return false;
821 };
822 let Some(ref hs) = result.report.health_score else {
823 return false;
824 };
825 if hs.score >= threshold {
826 return false;
827 }
828
829 if !options.quiet {
830 eprintln!(
831 "Health score {:.1} ({}) is below minimum threshold {:.0}",
832 hs.score, hs.grade, threshold
833 );
834 }
835 true
836}
837
838fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
839 if let Some(min_sev) = options.gates.min_severity {
840 result.report.findings.iter().any(|f| f.severity >= min_sev)
841 } else if options.gates.min_score.is_none() {
842 !result.report.findings.is_empty()
843 } else {
844 false
845 }
846}
847
848fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
849 result
850 .report
851 .runtime_coverage
852 .as_ref()
853 .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
854}
855
856fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
857 matches!(
858 finding.verdict,
859 fallow_output::RuntimeCoverageVerdict::SafeToDelete
860 | fallow_output::RuntimeCoverageVerdict::ReviewRequired
861 | fallow_output::RuntimeCoverageVerdict::LowTraffic
862 )
863}
864
865fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions<'_>) {
866 if options.gates.min_score.is_none()
867 || options.gates.min_severity.is_some()
868 || options.quiet
869 || result.report.findings.is_empty()
870 || !matches!(result.config.output, OutputFormat::Human)
871 {
872 return;
873 }
874
875 {
876 eprintln!(
877 "{}",
878 "Findings above are informational: --min-score gates on the score, not on findings."
879 .dimmed()
880 );
881 }
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887 use fallow_config::{FallowConfig, OutputFormat};
888 use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
889 use std::path::PathBuf;
890 use std::time::Duration;
891
892 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
893 ComplexityViolation {
894 path: PathBuf::from("/project/src/a.ts"),
895 name: name.to_string(),
896 line: 1,
897 col: 0,
898 cyclomatic: match exceeded {
899 ExceededThreshold::Cyclomatic
900 | ExceededThreshold::Both
901 | ExceededThreshold::CyclomaticCrap
902 | ExceededThreshold::All => 25,
903 _ => 8,
904 },
905 cognitive: match exceeded {
906 ExceededThreshold::Cognitive
907 | ExceededThreshold::Both
908 | ExceededThreshold::CognitiveCrap
909 | ExceededThreshold::All => 20,
910 _ => 5,
911 },
912 line_count: 10,
913 param_count: 0,
914 react_hook_count: 0,
915 react_jsx_max_depth: 0,
916 react_prop_count: 0,
917 react_hook_profile: None,
918 exceeded,
919 severity: FindingSeverity::Moderate,
920 crap: exceeded.includes_crap().then_some(30.0),
921 coverage_pct: None,
922 coverage_tier: None,
923 coverage_source: None,
924 inherited_from: None,
925 component_rollup: None,
926 contributions: Vec::new(),
927 effective_thresholds: None,
928 threshold_source: None,
929 }
930 }
931
932 fn test_resolved_config() -> fallow_config::ResolvedConfig {
933 FallowConfig::default().resolve(
934 PathBuf::from("/project"),
935 OutputFormat::Json,
936 1,
937 true,
938 true,
939 None,
940 )
941 }
942
943 fn fx_summary(
944 tracked: usize,
945 hit: usize,
946 unhit: usize,
947 untracked: usize,
948 ) -> fallow_output::RuntimeCoverageSummary {
949 #[expect(
950 clippy::cast_precision_loss,
951 reason = "test fixture totals are tiny, f64 precision is fine"
952 )]
953 let coverage_percent = if tracked == 0 {
954 0.0
955 } else {
956 (hit as f64 / tracked as f64) * 100.0
957 };
958 fallow_output::RuntimeCoverageSummary {
959 data_source: fallow_output::RuntimeCoverageDataSource::Local,
960 last_received_at: None,
961 functions_tracked: tracked,
962 functions_hit: hit,
963 functions_unhit: unhit,
964 functions_untracked: untracked,
965 coverage_percent,
966 trace_count: 512,
967 period_days: 7,
968 deployments_seen: 2,
969 capture_quality: None,
970 }
971 }
972
973 fn fx_evidence(
974 static_status: &str,
975 test_coverage: &str,
976 v8_tracking: &str,
977 ) -> fallow_output::RuntimeCoverageEvidence {
978 fallow_output::RuntimeCoverageEvidence {
979 static_status: static_status.to_owned(),
980 test_coverage: test_coverage.to_owned(),
981 test_only_reference: None,
982 v8_tracking: v8_tracking.to_owned(),
983 untracked_reason: None,
984 observation_days: 7,
985 deployments_observed: 2,
986 }
987 }
988
989 fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
990 fallow_output::HealthScore {
991 formula_version: 2,
992 score,
993 grade,
994 penalties: fallow_output::HealthScorePenalties {
995 dead_files: None,
996 dead_exports: None,
997 complexity: 0.0,
998 p90_complexity: 0.0,
999 maintainability: None,
1000 hotspots: None,
1001 unused_deps: None,
1002 circular_deps: None,
1003 unit_size: None,
1004 coupling: None,
1005 duplication: None,
1006 prop_drilling: None,
1007 },
1008 }
1009 }
1010
1011 fn fx_gate_result(
1012 findings: Vec<fallow_output::HealthFinding>,
1013 score: Option<fallow_output::HealthScore>,
1014 ) -> HealthResult {
1015 HealthResult {
1016 branching_by_file: fallow_engine::health::BranchingByFile::default(),
1017 report: fallow_output::HealthReport {
1018 findings,
1019 health_score: score,
1020 ..fallow_output::HealthReport::default()
1021 },
1022 grouping: None,
1023 group_resolver: None,
1024 config: test_resolved_config(),
1025 workspace_diagnostics: Vec::new(),
1026 elapsed: Duration::default(),
1027 timings: None,
1028 type_aware_meta: None,
1029 coverage_gaps_has_findings: false,
1030 should_fail_on_coverage_gaps: false,
1031 }
1032 }
1033
1034 fn moderate_finding() -> fallow_output::HealthFinding {
1035 make_finding("moderate", ExceededThreshold::Cyclomatic).into()
1036 }
1037
1038 fn critical_finding() -> fallow_output::HealthFinding {
1039 let mut v = make_finding("critical", ExceededThreshold::All);
1040 v.severity = FindingSeverity::Critical;
1041 v.into()
1042 }
1043
1044 fn gate_exit(
1046 result: &HealthResult,
1047 min_score: Option<f64>,
1048 min_severity: Option<FindingSeverity>,
1049 report_only: bool,
1050 ) -> ExitCode {
1051 print_health_result(
1052 result,
1053 HealthPrintOptions {
1054 quiet: true,
1055 explain: false,
1056 gates: HealthGateOptions {
1057 min_score,
1058 min_severity,
1059 report_only,
1060 fail_on_stale_baseline: false,
1061 },
1062 baseline_path: None,
1063 summary: false,
1064 summary_heading: true,
1065 show_explain_tip: true,
1066 type_aware_scope: None,
1067 skip_score_and_trend: false,
1068 css_requested: false,
1069 json_style: crate::json_style::JsonStyle::Compact,
1070 },
1071 )
1072 }
1073
1074 #[test]
1075 fn plain_health_with_findings_fails() {
1076 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1077 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1078 }
1079
1080 #[test]
1081 fn plain_health_with_no_findings_succeeds() {
1082 let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1083 assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
1084 }
1085
1086 #[test]
1087 fn min_score_zero_never_fails_even_with_findings() {
1088 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1089 assert_eq!(
1090 gate_exit(&result, Some(0.0), None, false),
1091 ExitCode::SUCCESS
1092 );
1093 }
1094
1095 #[test]
1096 fn min_score_passing_demotes_findings_to_informational() {
1097 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1098 assert_eq!(
1099 gate_exit(&result, Some(80.0), None, false),
1100 ExitCode::SUCCESS
1101 );
1102 }
1103
1104 #[test]
1105 fn min_score_below_threshold_fails() {
1106 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1107 assert_eq!(
1108 gate_exit(&result, Some(80.0), None, false),
1109 ExitCode::from(1)
1110 );
1111 }
1112
1113 #[test]
1114 fn min_severity_gates_on_severity_independent_of_min_score() {
1115 let only_moderate =
1116 fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1117 assert_eq!(
1118 gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
1119 ExitCode::SUCCESS,
1120 );
1121 let with_critical = fx_gate_result(
1122 vec![moderate_finding(), critical_finding()],
1123 Some(fx_health_score(87.5, "A")),
1124 );
1125 assert_eq!(
1126 gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
1127 ExitCode::from(1),
1128 );
1129 }
1130
1131 #[test]
1132 fn min_score_and_min_severity_compose_as_or() {
1133 let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1134 assert_eq!(
1135 gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
1136 ExitCode::SUCCESS,
1137 );
1138 let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1139 assert_eq!(
1140 gate_exit(
1141 &low_score,
1142 Some(80.0),
1143 Some(FindingSeverity::Critical),
1144 false
1145 ),
1146 ExitCode::from(1),
1147 );
1148 let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
1149 assert_eq!(
1150 gate_exit(
1151 &critical,
1152 Some(80.0),
1153 Some(FindingSeverity::Critical),
1154 false
1155 ),
1156 ExitCode::from(1),
1157 );
1158 }
1159
1160 #[test]
1161 fn report_only_never_fails_on_findings_or_low_score() {
1162 let result = fx_gate_result(
1163 vec![moderate_finding(), critical_finding()],
1164 Some(fx_health_score(10.0, "F")),
1165 );
1166 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1167 }
1168
1169 #[test]
1170 fn runtime_coverage_gate_independent_of_min_score() {
1171 let result = fx_low_traffic_runtime_result();
1172 assert_eq!(
1173 gate_exit(&result, Some(0.0), None, false),
1174 ExitCode::from(1)
1175 );
1176 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1177 }
1178
1179 fn fx_low_traffic_runtime_result() -> HealthResult {
1180 HealthResult {
1181 branching_by_file: fallow_engine::health::BranchingByFile::default(),
1182 report: fallow_output::HealthReport {
1183 runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
1184 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
1185 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
1186 signals: Vec::new(),
1187 summary: fx_summary(1, 0, 1, 0),
1188 findings: vec![fallow_output::RuntimeCoverageFinding {
1189 id: "fallow:prod:lowtraffic".to_owned(),
1190 stable_id: None,
1191 path: PathBuf::from("/project/src/cold.ts"),
1192 function: "coldPath".to_owned(),
1193 line: 14,
1194 verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
1195 invocations: Some(1),
1196 confidence: fallow_output::RuntimeCoverageConfidence::Low,
1197 evidence: fx_evidence("used", "not_covered", "tracked"),
1198 actions: vec![],
1199 source_hash: None,
1200 discriminators: None,
1201 }],
1202 hot_paths: vec![],
1203 blast_radius: vec![],
1204 importance: vec![],
1205 watermark: None,
1206 warnings: vec![],
1207 actionable: true,
1208 actionability_reason: None,
1209 actionability_verdict: None,
1210 provenance: fallow_output::RuntimeCoverageProvenance::default(),
1211 }),
1212 ..fallow_output::HealthReport::default()
1213 },
1214 grouping: None,
1215 group_resolver: None,
1216 config: test_resolved_config(),
1217 workspace_diagnostics: Vec::new(),
1218 elapsed: Duration::default(),
1219 timings: None,
1220 type_aware_meta: None,
1221 coverage_gaps_has_findings: false,
1222 should_fail_on_coverage_gaps: false,
1223 }
1224 }
1225
1226 #[test]
1227 fn print_health_result_fails_on_low_traffic_runtime_coverage() {
1228 let result = fx_low_traffic_runtime_result();
1229
1230 assert_eq!(
1231 print_health_result(
1232 &result,
1233 HealthPrintOptions {
1234 quiet: true,
1235 explain: false,
1236 gates: HealthGateOptions::default(),
1237 baseline_path: None,
1238 summary: false,
1239 summary_heading: true,
1240 show_explain_tip: true,
1241 type_aware_scope: None,
1242 skip_score_and_trend: false,
1243 css_requested: false,
1244 json_style: crate::json_style::JsonStyle::Compact,
1245 },
1246 ),
1247 ExitCode::from(1),
1248 );
1249 }
1250}