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