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