1pub mod coverage;
12pub mod mutation_report;
13pub mod optimization_target;
14
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 HealthError, HealthExecutionOptions, HealthGateOptions, HealthGroupResolver,
26 HealthPipelineInputs, HealthScopeInputs, HealthSeams, HealthSharedParseData, HealthSort,
27 RuntimeCoverageSeamInput, execute_health_inner, validate_health_churn_file,
28};
29
30use crate::check::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
57pub struct TypeAwareHealthOptions<'a> {
59 pub enabled: Option<bool>,
62 pub requested: bool,
63 pub unfiltered: bool,
64 pub projects: &'a [std::path::PathBuf],
65 pub require: Option<fallow_config::TypeAwareRequire>,
66}
67
68impl HealthGroupResolver for OwnershipResolver {
69 fn mode_label(&self) -> &'static str {
70 OwnershipResolver::mode_label(self)
71 }
72
73 fn resolve_with_rule(&self, rel_path: &std::path::Path) -> (String, Option<String>) {
74 OwnershipResolver::resolve_with_rule(self, rel_path)
75 }
76
77 fn section_owners_of(&self, rel_path: &std::path::Path) -> Option<&[String]> {
78 OwnershipResolver::section_owners_of(self, rel_path)
79 }
80}
81
82fn health_diff_index<'a>(opts: &HealthOptions<'a>) -> Option<&'a fallow_output::DiffIndex> {
85 match opts.diff_index {
86 Some(index) => Some(index),
87 None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(),
88 None => None,
89 }
90}
91
92fn build_health_group_resolver(
94 opts: &HealthOptions<'_>,
95 config: &fallow_config::ResolvedConfig,
96) -> Result<Option<OwnershipResolver>, ExitCode> {
97 crate::runtime_support::build_ownership_resolver_for_mode(
98 opts.group_by,
99 opts.root,
100 config.codeowners.as_deref(),
101 &config.workspace_patterns,
102 opts.output,
103 )
104}
105
106fn record_health_telemetry(report: &fallow_output::HealthReport, coverage_gaps_has_findings: bool) {
110 if coverage_gaps_has_findings && report.findings.is_empty() {
111 crate::telemetry::note_findings_present(true);
112 } else {
113 crate::telemetry::note_result_count(report.findings.len());
114 }
115 crate::telemetry::note_analysis_scale(
116 Some(report.summary.files_analyzed),
117 Some(report.summary.functions_analyzed),
118 );
119}
120
121fn health_seams<'a>() -> HealthSeams<'a> {
124 HealthSeams {
125 runtime_coverage_analyzer: &runtime_coverage_seam,
126 note_graph_structure: &|module_count, edge_count| {
127 crate::telemetry::note_graph_structure_counts(module_count, edge_count);
128 },
129 }
130}
131
132#[expect(
136 clippy::needless_pass_by_value,
137 reason = "by-value input matches the engine RuntimeCoverageAnalyzer seam signature"
138)]
139fn runtime_coverage_seam(
140 options: &fallow_engine::health::RuntimeCoverageOptions,
141 input: RuntimeCoverageSeamInput<'_>,
142) -> Result<fallow_output::RuntimeCoverageReport, u8> {
143 coverage::analyze(
144 options,
145 &coverage::RuntimeCoverageAnalysisInput {
146 root: input.root,
147 modules: input.modules,
148 analysis_output: input.analysis_output,
149 istanbul_coverage: input.istanbul_coverage,
150 file_paths: input.file_paths,
151 ignore_set: input.ignore_set,
152 changed_files: input.changed_files,
153 ws_roots: input.ws_roots,
154 top: input.top,
155 codeowners_path: input.codeowners_path,
156 quiet: input.quiet,
157 output: input.output,
158 },
159 )
160}
161
162fn build_health_scope_inputs<'a>(
168 opts: &HealthOptions<'a>,
169 config: &fallow_config::ResolvedConfig,
170 files: &[fallow_types::discover::DiscoveredFile],
171) -> Result<HealthScopeInputs<'a, OwnershipResolver>, ExitCode> {
172 let changed_files = opts
173 .changed_since
174 .and_then(|git_ref| crate::requests::resolve_changed_since(opts.root, git_ref));
175 crate::requests::measure_changed_since_scope(files);
176 let diff_index = health_diff_index(opts);
177 let mut ws_roots = resolve_workspace_scope(
178 opts.root,
179 opts.workspace,
180 opts.changed_workspaces,
181 &config.workspace_patterns,
182 opts.output,
183 )?;
184 if let Some(scope) = opts.scope.as_ref() {
185 ws_roots.get_or_insert_with(Vec::new).push(scope.clone());
186 }
187 let group_resolver = build_health_group_resolver(opts, config)?;
188 Ok(HealthScopeInputs {
189 changed_files,
190 diff_index,
191 ws_roots,
192 group_resolver,
193 })
194}
195
196fn health_err_to_exit(error: HealthError, output: OutputFormat) -> ExitCode {
201 match error {
202 HealthError::Message { message, exit_code } => emit_error(&message, exit_code, output),
203 HealthError::Printed(code) => ExitCode::from(code),
204 }
205}
206
207pub fn load_health_config(
210 opts: &HealthOptions<'_>,
211) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
212 if let Some(code) = crate::baseline_gate::refuse_save_before_analysis(
213 opts.save_baseline,
214 fallow_engine::baseline::BaselineKind::Health,
215 opts.output,
216 ) {
217 return Err(code);
218 }
219 fallow_engine::health::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
220 .map_err(|e| emit_error(&e, 2, opts.output))?;
221 validate_health_churn_file(opts).map_err(|e| health_err_to_exit(e, opts.output))?;
222 let t = Instant::now();
223 let mut config = crate::load_config_for_analysis(
224 opts.root,
225 opts.config_path,
226 crate::ConfigLoadOptions {
227 output: opts.output,
228 no_cache: opts.no_cache,
229 threads: opts.threads,
230 production_override:
231 fallow_engine::project_config::ProductionFlags::single_analysis_override(
232 opts.production,
233 opts.production_override,
234 ),
235 quiet: opts.quiet,
236 allow_remote_extends: opts.allow_remote_extends,
237 },
238 fallow_config::ProductionAnalysis::Health,
239 )?;
240 if opts.gates.fail_on_parse_error {
241 config.fail_on_parse_error = true;
242 }
243 let config_ms = t.elapsed().as_secs_f64() * 1000.0;
244 Ok((config, config_ms))
245}
246
247pub fn execute_health_with_shared_parse(
253 opts: &HealthOptions<'_>,
254 shared: HealthSharedParseData,
255 pre_computed_duplication: Option<fallow_engine::duplicates::DuplicationReport>,
256) -> Result<HealthResult, ExitCode> {
257 let (config, config_ms) = load_health_config(opts)?;
258 let scope_inputs = build_health_scope_inputs(opts, &config, &shared.files)?;
259 let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
260 let workspaces = shared.workspaces;
261 let seams = health_seams();
262 let result = execute_health_inner(
263 opts,
264 HealthPipelineInputs {
265 config,
266 files: shared.files,
267 modules: shared.modules,
268 config_ms,
269 discover_ms: 0.0,
270 parse_ms: 0.0,
271 parse_cpu_ms: 0.0,
272 shared_parse: true,
273 pre_computed_analysis: shared.analysis_output,
274 dead_code_results: shared.dead_code_results,
275 styling_artifacts: None,
276 pre_computed_duplication,
277 workspaces,
278 workspace_diagnostics,
279 },
280 scope_inputs,
281 &seams,
282 )
283 .map_err(|e| health_err_to_exit(e, opts.output))?;
284 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
285 Ok(result)
286}
287
288pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
289 let (config, config_ms) = load_health_config(opts)?;
290 execute_health_with_config(opts, config, config_ms)
291}
292
293pub fn execute_health_with_config(
294 opts: &HealthOptions<'_>,
295 config: fallow_config::ResolvedConfig,
296 config_ms: f64,
297) -> Result<HealthResult, ExitCode> {
298 let seams = health_seams();
299 let result = execute_health_with_config_and_seams(opts, config, config_ms, &seams)?;
300 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
301 Ok(result)
302}
303
304fn execute_health_with_config_and_seams(
305 opts: &HealthOptions<'_>,
306 config: fallow_config::ResolvedConfig,
307 config_ms: f64,
308 seams: &HealthSeams<'_>,
309) -> Result<HealthResult, ExitCode> {
310 let t = Instant::now();
311 let session = fallow_engine::session::AnalysisSession::from_resolved_config(config)
312 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
313 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
314 let parts = session.parsed_parts_uncached(true);
315 let pre_computed_analysis =
316 fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
317 .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
318 .transpose()
319 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
320 let config = parts.config;
321 let files = parts.files;
322 let modules = parts.modules;
323 let workspaces = parts.workspaces;
324 let workspace_diagnostics = if pre_computed_analysis.is_some() {
325 session.current_workspace_diagnostics()
326 } else {
327 parts.workspace_diagnostics
328 };
329 let parse_ms = parts.parse_ms;
330 let parse_cpu_ms = parts.parse_cpu_ms;
331
332 let scope_inputs = build_health_scope_inputs(opts, &config, &files)?;
333 execute_health_inner(
334 opts,
335 HealthPipelineInputs {
336 config,
337 files,
338 modules,
339 config_ms,
340 discover_ms,
341 parse_ms,
342 parse_cpu_ms,
343 shared_parse: false,
344 dead_code_results: None,
345 styling_artifacts: None,
346 pre_computed_analysis,
347 pre_computed_duplication: None,
348 workspaces,
349 workspace_diagnostics,
350 },
351 scope_inputs,
352 seams,
353 )
354 .map_err(|e| health_err_to_exit(e, opts.output))
355}
356
357pub fn benchmark_execute_health_with_response(
358 opts: &HealthOptions<'_>,
359 response_bytes: &[u8],
360 request_len: &std::cell::Cell<usize>,
361) -> Result<HealthResult, ExitCode> {
362 let analyzer = |options: &fallow_engine::health::RuntimeCoverageOptions,
363 input: RuntimeCoverageSeamInput<'_>| {
364 coverage::analyze_with_transport(
365 options,
366 &coverage::RuntimeCoverageAnalysisInput {
367 root: input.root,
368 modules: input.modules,
369 analysis_output: input.analysis_output,
370 istanbul_coverage: input.istanbul_coverage,
371 file_paths: input.file_paths,
372 ignore_set: input.ignore_set,
373 changed_files: input.changed_files,
374 ws_roots: input.ws_roots,
375 top: input.top,
376 codeowners_path: input.codeowners_path,
377 quiet: input.quiet,
378 output: input.output,
379 },
380 |request, _quiet, output| {
381 let (response, len) =
382 coverage::in_process_response_transport(request, response_bytes, output)?;
383 request_len.set(len);
384 Ok(response)
385 },
386 )
387 };
388 let seams = HealthSeams {
389 runtime_coverage_analyzer: &analyzer,
390 note_graph_structure: &|_module_count, _edge_count| {},
391 };
392 let (config, config_ms) = load_health_config(opts)?;
393 execute_health_with_config_and_seams(opts, config, config_ms, &seams)
394}
395
396pub fn run_health(
397 opts: &HealthOptions<'_>,
398 json_style: crate::json_style::JsonStyle,
399 type_aware: &TypeAwareHealthOptions<'_>,
400 mutation_report: Option<&std::path::Path>,
401) -> ExitCode {
402 let mutation_evidence = match mutation_report.map(mutation_report::read_mutation_evidence) {
403 Some(Err(error)) => {
404 return crate::error::emit_programmatic_error(&error, opts.output, json_style);
405 }
406 evidence => evidence.and_then(Result::ok),
407 };
408 let (config, config_ms) = match load_health_config(opts) {
409 Ok(config) => config,
410 Err(code) => return code,
411 };
412 let resolved_type_aware = match resolve_type_aware_health_options(type_aware, &config) {
413 Ok(options) => options,
414 Err(message) => return emit_error(&message, 2, opts.output),
415 };
416 let requested = type_aware.requested || (type_aware.unfiltered && resolved_type_aware.enabled);
417 let mut degraded_meta = None;
418 let semantic = if requested {
419 let enabled = resolved_type_aware.enabled;
420 if !enabled {
421 return emit_error(
422 "--type-coupling requires --type-aware or typeAware.enabled in config",
423 2,
424 opts.output,
425 );
426 }
427 let projects = resolved_type_aware.projects;
428 let require = resolved_type_aware.require;
429 match fallow_api::analyze_type_coupling(opts.root, &projects, &[]) {
430 Ok(outcome) => Some(outcome),
431 Err(error) => {
432 match crate::type_aware_degrade::degrade_or_fail(
433 &crate::type_aware_degrade::DegradeContext {
434 root: opts.root,
435 error: &error.to_string(),
436 failure_label: "Type-aware coupling failed",
437 require,
438 quiet: opts.quiet,
439 output: opts.output,
440 },
441 ) {
442 Ok(meta) => degraded_meta = Some(meta),
443 Err(code) => return code,
444 }
445 None
446 }
447 }
448 } else {
449 None
450 };
451 let mut execution_opts = opts.clone();
452 if let Some(identity) = semantic
453 .as_ref()
454 .and_then(|outcome| outcome.type_aware.meta.identity.clone())
455 {
456 execution_opts.analysis_identity = identity;
457 }
458 let mut result = match execute_health_with_config(&execution_opts, config, config_ms) {
459 Ok(result) => result,
460 Err(code) => return code,
461 };
462 let required_completeness = resolved_type_aware.require.into();
465 result.type_aware_meta = semantic
466 .map(|outcome| {
467 let mut meta = outcome.type_aware.meta;
468 meta.required_completeness = Some(required_completeness);
469 meta
470 })
471 .or(degraded_meta);
472 if let Some(evidence) = mutation_evidence.as_ref() {
473 mutation_report::attach_to_result(&mut result, evidence);
474 }
475 if let Some(ref timings) = result.timings {
476 report::print_health_performance(timings, opts.output, json_style);
477 }
478 report_loaded_baseline(&mut result, opts.baseline);
479 if let Some(grouping) = result.grouping.as_ref() {
480 crate::requests::record_group_filter(grouping, opts.quiet);
481 }
482 let gates = fallow_engine::health::HealthGateOptions {
485 fail_on_parse_error: result.config.fail_on_parse_error,
486 ..opts.gates
487 };
488 print_health_result(
489 &result,
490 HealthPrintOptions {
491 quiet: opts.quiet,
492 explain: opts.explain,
493 gates,
494 baseline_path: opts.baseline,
495 summary: opts.summary,
496 summary_heading: true,
497 show_explain_tip: true,
498 type_aware_scope: None,
499 skip_score_and_trend: false,
500 css_requested: opts.css,
501 json_style,
502 exit_reason: true,
503 },
504 )
505}
506
507pub struct ResolvedTypeAwareHealthOptions {
508 pub enabled: bool,
509 pub projects: Vec<std::path::PathBuf>,
510 pub require: fallow_config::TypeAwareRequire,
511}
512
513pub fn resolve_type_aware_health_options(
514 options: &TypeAwareHealthOptions<'_>,
515 config: &fallow_config::ResolvedConfig,
516) -> Result<ResolvedTypeAwareHealthOptions, String> {
517 let env_enabled = std::env::var("FALLOW_TYPE_AWARE")
518 .ok()
519 .map(|value| match value.trim().to_ascii_lowercase().as_str() {
520 "1" | "true" | "yes" | "on" => Ok(true),
521 "0" | "false" | "no" | "off" => Ok(false),
522 _ => Err(
523 "FALLOW_TYPE_AWARE must be one of true, false, 1, 0, yes, no, on, or off"
524 .to_string(),
525 ),
526 })
527 .transpose()?;
528 let enabled = options
529 .enabled
530 .or(env_enabled)
531 .unwrap_or(config.type_aware.enabled);
532 let projects = if !options.projects.is_empty() {
533 options.projects.to_vec()
534 } else if let Some(value) = std::env::var_os("FALLOW_TYPE_AWARE_PROJECTS") {
535 std::env::split_paths(&value).collect()
536 } else {
537 config
538 .type_aware
539 .projects
540 .iter()
541 .map(std::path::PathBuf::from)
542 .collect()
543 };
544 let require = if let Some(require) = options.require {
545 require
546 } else if let Ok(value) = std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
547 match value.trim().to_ascii_lowercase().as_str() {
548 "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
549 "complete" => fallow_config::TypeAwareRequire::Complete,
550 _ => {
551 return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete".to_string());
552 }
553 }
554 } else {
555 config.type_aware.require
556 };
557 Ok(ResolvedTypeAwareHealthOptions {
558 enabled,
559 projects,
560 require,
561 })
562}
563
564pub type HealthResult =
566 fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
567
568#[derive(Clone, Copy)]
589pub struct HealthPrintOptions<'a> {
590 pub quiet: bool,
591 pub explain: bool,
592 pub gates: HealthGateOptions,
593 pub baseline_path: Option<&'a std::path::Path>,
597 pub summary: bool,
598 pub summary_heading: bool,
599 pub show_explain_tip: bool,
600 pub type_aware_scope: Option<&'static str>,
601 pub skip_score_and_trend: bool,
602 pub css_requested: bool,
606 pub json_style: crate::json_style::JsonStyle,
607 pub exit_reason: bool,
611}
612
613pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions<'_>) -> ExitCode {
614 let ctx = health_report_context(result, options);
615 let report_code = report::print_health_report(
616 &result.report,
617 result.grouping.as_ref(),
618 result.group_resolver.as_ref(),
619 &ctx,
620 result.config.output,
621 );
622 if report_code != ExitCode::SUCCESS {
623 return report_code;
624 }
625
626 let type_aware_code = crate::exit_codes::gate_failed_exit_code(
629 fallow_output::GateName::TypeAwareRequire,
630 type_aware_completeness_incomplete(result),
631 );
632 let code = if options.gates.report_only {
633 note_stale_baseline_gate_stood_down(result, options);
634 type_aware_code
635 } else {
636 health_exit_code(result, options).max(type_aware_code)
637 };
638 if code == 0 {
639 if !options.gates.report_only {
640 maybe_print_score_gate_note(result, options);
641 }
642 } else if options.exit_reason {
643 crate::gates::print_exit_reason(&crate::gates::ExitReason {
644 gates: health_gate_outcomes(result, options).as_ref(),
645 code,
646 fail_on_issues: options.gates.fail_on_issues,
647 quiet: options.quiet,
648 output: result.config.output,
649 own_lines: &[],
650 });
651 }
652 crate::exit_codes::run_exit_code([code])
653}
654
655pub fn type_aware_completeness_incomplete(result: &HealthResult) -> bool {
658 crate::report::ci::required_type_aware_incomplete(result.type_aware_meta.as_ref())
659}
660
661fn health_report_context<'a>(
662 result: &'a HealthResult,
663 options: HealthPrintOptions<'a>,
664) -> report::ReportContext<'a> {
665 report::ReportContext {
666 package_baselines: &[],
667 root: &result.config.root,
668 rules: &result.config.rules,
669 workspace_diagnostics: &result.workspace_diagnostics,
670 elapsed: result.elapsed,
671 quiet: options.quiet,
672 explain: options.explain,
673 type_aware: result.type_aware_meta.as_ref(),
674 type_aware_scope: options.type_aware_scope,
675 group_by: None,
676 top: None,
677 summary: options.summary,
678 summary_heading: options.summary_heading,
679 show_explain_tip: options.show_explain_tip,
680 baseline_matched: None,
681 baseline_staleness: None,
682 finding_id_query: None,
683 gate_outcomes: health_gate_outcomes(result, options),
684 config_fixable: false,
685 failed_parse_files: 0,
686 skip_score_and_trend: options.skip_score_and_trend,
687 css_requested: options.css_requested,
688 json_style: options.json_style,
689 include_fragments: true,
690 architecture_layout: report::ArchitectureLayout::Embedded,
691 }
692}
693
694fn health_gate_outcomes(
697 result: &HealthResult,
698 options: HealthPrintOptions<'_>,
699) -> Option<fallow_output::GateOutcomes> {
700 crate::gates::health_gate_outcomes(&crate::gates::HealthGateInputs {
701 report_only: options.gates.report_only,
702 min_score: options.gates.min_score.map(|threshold| {
703 (
704 threshold,
705 result.report.health_score.as_ref().map(|score| score.score),
706 )
707 }),
708 min_severity: options.gates.min_severity.map(|floor| {
709 (
710 floor,
711 blocking_findings(result)
712 .filter(|finding| finding.severity >= floor)
713 .count(),
714 )
715 }),
716 coverage_gaps: result
717 .should_fail_on_coverage_gaps
718 .then_some(result.coverage_gaps_has_findings),
719 runtime_coverage: result
720 .report
721 .runtime_coverage
722 .is_some()
723 .then(|| has_failing_runtime_coverage(result)),
724 baseline_staleness: result.report.summary.baseline_staleness.as_ref(),
725 fail_on_stale_baseline: options.gates.fail_on_stale_baseline,
726 blocking_findings: blocking_findings(result).count(),
727 type_aware_meta: result.type_aware_meta.as_ref(),
728 parse_error: health_parse_error_outcome(result, options),
729 })
730}
731
732fn health_parse_error_outcome(
736 result: &HealthResult,
737 options: HealthPrintOptions<'_>,
738) -> Option<fallow_output::GateOutcome> {
739 crate::gates::parse_error_outcome(
740 options.gates.fail_on_parse_error,
741 true,
742 &crate::gates::parse_degraded_files(&result.config.root, &result.workspace_diagnostics),
743 )
744}
745
746fn health_exit_code(result: &HealthResult, options: HealthPrintOptions<'_>) -> u8 {
756 use crate::exit_codes::gate_failed_exit_code;
757 use fallow_output::GateName;
758
759 let findings_gate = if options.gates.min_severity.is_some() {
760 GateName::HealthMinSeverity
761 } else {
762 GateName::HealthFindings
763 };
764 let parse_error = health_parse_error_outcome(result, options);
765 let parse_error_failed = parse_error
766 .as_ref()
767 .is_some_and(fallow_output::GateOutcome::fails_run);
768 if let Some(outcome) = parse_error.as_ref().filter(|_| parse_error_failed) {
769 crate::gates::print_parse_error_gate_failure(&outcome.files);
770 }
771 [
772 gate_failed_exit_code(GateName::HealthMinScore, score_gate_failed(result, options)),
773 gate_failed_exit_code(findings_gate, findings_gate_failed(result, options)),
774 gate_failed_exit_code(
775 GateName::HealthRuntimeCoverage,
776 has_failing_runtime_coverage(result),
777 ),
778 gate_failed_exit_code(
779 GateName::StaleBaseline,
780 stale_baseline_gate_failed(result, options),
781 ),
782 gate_failed_exit_code(
783 GateName::HealthCoverageGaps,
784 result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings,
785 ),
786 gate_failed_exit_code(GateName::ParseError, parse_error_failed),
787 ]
788 .into_iter()
789 .max()
790 .unwrap_or(0)
791}
792
793fn report_loaded_baseline(result: &mut HealthResult, baseline_path: Option<&std::path::Path>) {
804 let Some(path) = baseline_path else {
805 return;
806 };
807 note_unrecognised_health_baseline(result, baseline_path, "--baseline");
808 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
809 return;
810 };
811 crate::output_runtime::set_loaded_baseline(crate::output_runtime::LoadedBaselineRecheck {
812 command: "health",
813 path: path.display().to_string(),
814 baseline_entries: staleness.baseline_entries,
815 scope_reasons: staleness.scope_reasons,
816 });
817}
818
819pub fn note_unrecognised_health_baseline(
836 result: &mut HealthResult,
837 baseline_path: Option<&std::path::Path>,
838 flag: &str,
839) {
840 let Some(path) = baseline_path else {
841 return;
842 };
843 let Some(staleness) = result.report.summary.baseline_staleness.as_mut() else {
844 return;
845 };
846 if !staleness.unrecognised_format {
847 return;
848 }
849 let saved_by = saved_by_another_command(path);
850 staleness.saved_by = saved_by.map(fallow_engine::baseline::BaselineKind::as_str);
851 crate::baseline_gate::note_unrecognised_baseline(
852 Some(path),
853 true,
854 saved_by,
855 fallow_engine::baseline::BaselineKind::Health,
856 flag,
857 );
858}
859
860fn saved_by_another_command(
864 path: &std::path::Path,
865) -> Option<fallow_engine::baseline::BaselineKind> {
866 let content = std::fs::read_to_string(path).ok()?;
867 fallow_engine::baseline::classify_baseline_file(
868 &content,
869 fallow_engine::baseline::BaselineKind::Health,
870 )
871 .saved_by()
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 fallow_engine::baseline::BaselineKind::Health,
908 )
909}
910
911fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
912 let Some(threshold) = options.gates.min_score else {
913 return false;
914 };
915 let Some(ref hs) = result.report.health_score else {
916 return false;
917 };
918 if hs.score >= threshold {
919 return false;
920 }
921
922 if !options.quiet && matches!(result.config.output, fallow_config::OutputFormat::Human) {
925 eprintln!(
926 "Health score {:.1} ({}) is below minimum threshold {:.0}",
927 hs.score, hs.grade, threshold
928 );
929 }
930 true
931}
932
933fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
934 if let Some(min_sev) = options.gates.min_severity {
935 blocking_findings(result).any(|f| f.severity >= min_sev)
936 } else if options.gates.min_score.is_none() {
937 blocking_findings(result).next().is_some()
938 } else {
939 false
940 }
941}
942
943fn blocking_findings(result: &HealthResult) -> impl Iterator<Item = &fallow_output::HealthFinding> {
948 result
949 .report
950 .findings
951 .iter()
952 .filter(|finding| finding.blocks())
953}
954
955fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
956 result
957 .report
958 .runtime_coverage
959 .as_ref()
960 .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
961}
962
963fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
964 matches!(
965 finding.verdict,
966 fallow_output::RuntimeCoverageVerdict::SafeToDelete
967 | fallow_output::RuntimeCoverageVerdict::ReviewRequired
968 | fallow_output::RuntimeCoverageVerdict::LowTraffic
969 )
970}
971
972fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions<'_>) {
973 if options.gates.min_score.is_none()
974 || options.gates.min_severity.is_some()
975 || options.quiet
976 || result.report.findings.is_empty()
977 || !matches!(result.config.output, OutputFormat::Human)
978 {
979 return;
980 }
981
982 {
983 eprintln!(
984 "{}",
985 "Findings above are informational: --min-score gates on the score, not on findings."
986 .dimmed()
987 );
988 }
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994 use fallow_config::{FallowConfig, OutputFormat};
995 use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
996 use std::path::PathBuf;
997 use std::time::Duration;
998
999 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
1000 ComplexityViolation {
1001 path: PathBuf::from("/project/src/a.ts"),
1002 name: name.to_string(),
1003 line: 1,
1004 col: 0,
1005 cyclomatic: match exceeded {
1006 ExceededThreshold::Cyclomatic
1007 | ExceededThreshold::Both
1008 | ExceededThreshold::CyclomaticCrap
1009 | ExceededThreshold::All => 25,
1010 _ => 8,
1011 },
1012 cognitive: match exceeded {
1013 ExceededThreshold::Cognitive
1014 | ExceededThreshold::Both
1015 | ExceededThreshold::CognitiveCrap
1016 | ExceededThreshold::All => 20,
1017 _ => 5,
1018 },
1019 line_count: 10,
1020 param_count: 0,
1021 react_hook_count: 0,
1022 react_jsx_max_depth: 0,
1023 react_prop_count: 0,
1024 react_hook_profile: None,
1025 exceeded,
1026 effective_severity: None,
1027 severity: FindingSeverity::Moderate,
1028 crap: exceeded.includes_crap().then_some(30.0),
1029 mutation: None,
1030 coverage_pct: None,
1031 coverage_tier: None,
1032 coverage_source: None,
1033 inherited_from: None,
1034 component_rollup: None,
1035 contributions: Vec::new(),
1036 effective_thresholds: None,
1037 threshold_source: None,
1038 }
1039 }
1040
1041 fn test_resolved_config() -> fallow_config::ResolvedConfig {
1042 FallowConfig::default().resolve(
1043 PathBuf::from("/project"),
1044 OutputFormat::Json,
1045 1,
1046 true,
1047 true,
1048 None,
1049 )
1050 }
1051
1052 fn fx_summary(
1053 tracked: usize,
1054 hit: usize,
1055 unhit: usize,
1056 untracked: usize,
1057 ) -> fallow_output::RuntimeCoverageSummary {
1058 #[expect(
1059 clippy::cast_precision_loss,
1060 reason = "test fixture totals are tiny, f64 precision is fine"
1061 )]
1062 let coverage_percent = if tracked == 0 {
1063 0.0
1064 } else {
1065 (hit as f64 / tracked as f64) * 100.0
1066 };
1067 fallow_output::RuntimeCoverageSummary {
1068 data_source: fallow_output::RuntimeCoverageDataSource::Local,
1069 last_received_at: None,
1070 functions_tracked: tracked,
1071 functions_hit: hit,
1072 functions_unhit: unhit,
1073 functions_untracked: untracked,
1074 coverage_percent,
1075 trace_count: 512,
1076 period_days: 7,
1077 deployments_seen: 2,
1078 capture_quality: None,
1079 }
1080 }
1081
1082 fn fx_evidence(
1083 static_status: &str,
1084 test_coverage: &str,
1085 v8_tracking: &str,
1086 ) -> fallow_output::RuntimeCoverageEvidence {
1087 fallow_output::RuntimeCoverageEvidence {
1088 static_status: static_status.to_owned(),
1089 test_coverage: test_coverage.to_owned(),
1090 test_only_reference: None,
1091 v8_tracking: v8_tracking.to_owned(),
1092 untracked_reason: None,
1093 observation_days: 7,
1094 deployments_observed: 2,
1095 }
1096 }
1097
1098 fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
1099 fallow_output::HealthScore {
1100 formula_version: 2,
1101 score,
1102 grade,
1103 penalties: fallow_output::HealthScorePenalties {
1104 dead_files: None,
1105 dead_exports: None,
1106 complexity: 0.0,
1107 p90_complexity: 0.0,
1108 maintainability: None,
1109 hotspots: None,
1110 unused_deps: None,
1111 circular_deps: None,
1112 unit_size: None,
1113 coupling: None,
1114 duplication: None,
1115 prop_drilling: None,
1116 },
1117 }
1118 }
1119
1120 fn fx_gate_result(
1121 findings: Vec<fallow_output::HealthFinding>,
1122 score: Option<fallow_output::HealthScore>,
1123 ) -> HealthResult {
1124 HealthResult {
1125 branching_by_file: fallow_engine::health::BranchingByFile::default(),
1126 report: fallow_output::HealthReport {
1127 findings,
1128 health_score: score,
1129 ..fallow_output::HealthReport::default()
1130 },
1131 grouping: None,
1132 group_resolver: None,
1133 config: test_resolved_config(),
1134 workspace_diagnostics: Vec::new(),
1135 elapsed: Duration::default(),
1136 timings: None,
1137 type_aware_meta: None,
1138 coverage_gaps_has_findings: false,
1139 should_fail_on_coverage_gaps: false,
1140 changed_files_analyzed: None,
1141 }
1142 }
1143
1144 fn moderate_finding() -> fallow_output::HealthFinding {
1145 make_finding("moderate", ExceededThreshold::Cyclomatic).into()
1146 }
1147
1148 fn critical_finding() -> fallow_output::HealthFinding {
1149 let mut v = make_finding("critical", ExceededThreshold::All);
1150 v.severity = FindingSeverity::Critical;
1151 v.into()
1152 }
1153
1154 fn gate_exit(
1156 result: &HealthResult,
1157 min_score: Option<f64>,
1158 min_severity: Option<FindingSeverity>,
1159 report_only: bool,
1160 ) -> ExitCode {
1161 print_health_result(
1162 result,
1163 HealthPrintOptions {
1164 quiet: true,
1165 explain: false,
1166 gates: HealthGateOptions {
1167 min_score,
1168 min_severity,
1169 report_only,
1170 fail_on_stale_baseline: false,
1171 fail_on_parse_error: false,
1172 fail_on_issues: false,
1173 },
1174 baseline_path: None,
1175 summary: false,
1176 summary_heading: true,
1177 show_explain_tip: true,
1178 type_aware_scope: None,
1179 skip_score_and_trend: false,
1180 css_requested: false,
1181 json_style: crate::json_style::JsonStyle::Compact,
1182 exit_reason: true,
1183 },
1184 )
1185 }
1186
1187 #[test]
1188 fn plain_health_with_findings_fails() {
1189 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1190 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1191 }
1192
1193 #[test]
1194 fn plain_health_with_no_findings_succeeds() {
1195 let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1196 assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
1197 }
1198
1199 #[test]
1200 fn min_score_zero_never_fails_even_with_findings() {
1201 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1202 assert_eq!(
1203 gate_exit(&result, Some(0.0), None, false),
1204 ExitCode::SUCCESS
1205 );
1206 }
1207
1208 #[test]
1209 fn min_score_passing_demotes_findings_to_informational() {
1210 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1211 assert_eq!(
1212 gate_exit(&result, Some(80.0), None, false),
1213 ExitCode::SUCCESS
1214 );
1215 }
1216
1217 #[test]
1218 fn min_score_below_threshold_fails() {
1219 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1220 assert_eq!(
1221 gate_exit(&result, Some(80.0), None, false),
1222 ExitCode::from(1)
1223 );
1224 }
1225
1226 #[test]
1227 fn min_severity_gates_on_severity_independent_of_min_score() {
1228 let only_moderate =
1229 fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1230 assert_eq!(
1231 gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
1232 ExitCode::SUCCESS,
1233 );
1234 let with_critical = fx_gate_result(
1235 vec![moderate_finding(), critical_finding()],
1236 Some(fx_health_score(87.5, "A")),
1237 );
1238 assert_eq!(
1239 gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
1240 ExitCode::from(1),
1241 );
1242 }
1243
1244 #[test]
1245 fn min_score_and_min_severity_compose_as_or() {
1246 let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1247 assert_eq!(
1248 gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
1249 ExitCode::SUCCESS,
1250 );
1251 let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1252 assert_eq!(
1253 gate_exit(
1254 &low_score,
1255 Some(80.0),
1256 Some(FindingSeverity::Critical),
1257 false
1258 ),
1259 ExitCode::from(1),
1260 );
1261 let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
1262 assert_eq!(
1263 gate_exit(
1264 &critical,
1265 Some(80.0),
1266 Some(FindingSeverity::Critical),
1267 false
1268 ),
1269 ExitCode::from(1),
1270 );
1271 }
1272
1273 #[test]
1274 fn report_only_never_fails_on_findings_or_low_score() {
1275 let result = fx_gate_result(
1276 vec![moderate_finding(), critical_finding()],
1277 Some(fx_health_score(10.0, "F")),
1278 );
1279 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1280 }
1281
1282 #[test]
1283 fn runtime_coverage_gate_independent_of_min_score() {
1284 let result = fx_low_traffic_runtime_result();
1285 assert_eq!(
1286 gate_exit(&result, Some(0.0), None, false),
1287 ExitCode::from(1)
1288 );
1289 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1290 }
1291
1292 fn fx_low_traffic_runtime_result() -> HealthResult {
1293 HealthResult {
1294 branching_by_file: fallow_engine::health::BranchingByFile::default(),
1295 report: fallow_output::HealthReport {
1296 runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
1297 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
1298 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
1299 signals: Vec::new(),
1300 summary: fx_summary(1, 0, 1, 0),
1301 findings: vec![fallow_output::RuntimeCoverageFinding {
1302 id: "fallow:prod:lowtraffic".to_owned(),
1303 stable_id: None,
1304 path: PathBuf::from("/project/src/cold.ts"),
1305 function: "coldPath".to_owned(),
1306 line: 14,
1307 verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
1308 invocations: Some(1),
1309 confidence: fallow_output::RuntimeCoverageConfidence::Low,
1310 evidence: fx_evidence("used", "not_covered", "tracked"),
1311 actions: vec![],
1312 source_hash: None,
1313 discriminators: None,
1314 }],
1315 hot_paths: vec![],
1316 blast_radius: vec![],
1317 importance: vec![],
1318 watermark: None,
1319 warnings: vec![],
1320 actionable: true,
1321 actionability_reason: None,
1322 actionability_verdict: None,
1323 provenance: fallow_output::RuntimeCoverageProvenance::default(),
1324 }),
1325 ..fallow_output::HealthReport::default()
1326 },
1327 grouping: None,
1328 group_resolver: None,
1329 config: test_resolved_config(),
1330 workspace_diagnostics: Vec::new(),
1331 elapsed: Duration::default(),
1332 timings: None,
1333 type_aware_meta: None,
1334 coverage_gaps_has_findings: false,
1335 should_fail_on_coverage_gaps: false,
1336 changed_files_analyzed: None,
1337 }
1338 }
1339
1340 #[test]
1341 fn print_health_result_fails_on_low_traffic_runtime_coverage() {
1342 let result = fx_low_traffic_runtime_result();
1343
1344 assert_eq!(
1345 print_health_result(
1346 &result,
1347 HealthPrintOptions {
1348 quiet: true,
1349 explain: false,
1350 gates: HealthGateOptions::default(),
1351 baseline_path: None,
1352 summary: false,
1353 summary_heading: true,
1354 show_explain_tip: true,
1355 type_aware_scope: None,
1356 skip_score_and_trend: false,
1357 css_requested: false,
1358 json_style: crate::json_style::JsonStyle::Compact,
1359 exit_reason: true,
1360 },
1361 ),
1362 ExitCode::from(1),
1363 );
1364 }
1365
1366 fn incomplete_required_type_aware_meta() -> fallow_types::envelope::TypeAwareMeta {
1369 fallow_types::envelope::TypeAwareMeta {
1370 required_completeness: Some(
1371 fallow_types::semantic::SemanticCompletenessRequirement::Complete,
1372 ),
1373 queries: vec![fallow_types::semantic::SemanticQuerySummary {
1374 query_id: 0,
1375 capability: fallow_types::semantic::SemanticCapability::TypeCoupling,
1376 assertion: "type coupling".to_string(),
1377 status: fallow_types::semantic::SemanticCompleteness::Partial,
1378 reason_code: None,
1379 total_evidence_count: 0,
1380 truncated: false,
1381 omissions: Vec::new(),
1382 actions: Vec::new(),
1383 }],
1384 ..Default::default()
1385 }
1386 }
1387
1388 #[test]
1392 fn a_failed_type_aware_completeness_gate_fails_the_health_print() {
1393 let mut result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1394 result.type_aware_meta = Some(incomplete_required_type_aware_meta());
1395 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1396 assert_eq!(gate_exit(&result, None, None, true), ExitCode::from(1));
1397
1398 let options = HealthPrintOptions {
1399 quiet: true,
1400 explain: false,
1401 gates: HealthGateOptions::default(),
1402 baseline_path: None,
1403 summary: false,
1404 summary_heading: true,
1405 show_explain_tip: true,
1406 type_aware_scope: None,
1407 skip_score_and_trend: false,
1408 css_requested: false,
1409 json_style: crate::json_style::JsonStyle::Compact,
1410 exit_reason: true,
1411 };
1412 let line = crate::report::gate_outcome_text::exit_reason_line(
1413 health_gate_outcomes(&result, options).as_ref(),
1414 1,
1415 false,
1416 &[],
1417 );
1418 assert_eq!(
1419 line.as_deref(),
1420 Some("Exit code 1: gate type-aware-require failed.")
1421 );
1422 }
1423
1424 #[test]
1425 fn the_envelope_states_a_failed_type_aware_completeness_gate() {
1426 let mut result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1427 result.type_aware_meta = Some(incomplete_required_type_aware_meta());
1428 let options = HealthPrintOptions {
1429 quiet: true,
1430 explain: false,
1431 gates: HealthGateOptions::default(),
1432 baseline_path: None,
1433 summary: false,
1434 summary_heading: true,
1435 show_explain_tip: true,
1436 type_aware_scope: None,
1437 skip_score_and_trend: false,
1438 css_requested: false,
1439 json_style: crate::json_style::JsonStyle::Compact,
1440 exit_reason: true,
1441 };
1442 let gates = serde_json::to_value(health_gate_outcomes(&result, options))
1443 .expect("gate outcomes serialize");
1444 assert_eq!(
1445 gates["type-aware-require"],
1446 serde_json::json!({ "status": "fail", "enforced": true }),
1447 "the run exits 1 on this gate, so the envelope must state it: {gates}"
1448 );
1449 }
1450}