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 opts.output,
101 )
102}
103
104fn record_health_telemetry(report: &fallow_output::HealthReport, coverage_gaps_has_findings: bool) {
108 if coverage_gaps_has_findings && report.findings.is_empty() {
109 crate::telemetry::note_findings_present(true);
110 } else {
111 crate::telemetry::note_result_count(report.findings.len());
112 }
113 crate::telemetry::note_analysis_scale(
114 Some(report.summary.files_analyzed),
115 Some(report.summary.functions_analyzed),
116 );
117}
118
119fn health_seams<'a>() -> HealthSeams<'a> {
122 HealthSeams {
123 runtime_coverage_analyzer: &runtime_coverage_seam,
124 note_graph_structure: &|module_count, edge_count| {
125 crate::telemetry::note_graph_structure_counts(module_count, edge_count);
126 },
127 }
128}
129
130#[expect(
134 clippy::needless_pass_by_value,
135 reason = "by-value input matches the engine RuntimeCoverageAnalyzer seam signature"
136)]
137fn runtime_coverage_seam(
138 options: &fallow_engine::health::RuntimeCoverageOptions,
139 input: RuntimeCoverageSeamInput<'_>,
140) -> Result<fallow_output::RuntimeCoverageReport, u8> {
141 coverage::analyze(
142 options,
143 &coverage::RuntimeCoverageAnalysisInput {
144 root: input.root,
145 modules: input.modules,
146 analysis_output: input.analysis_output,
147 istanbul_coverage: input.istanbul_coverage,
148 file_paths: input.file_paths,
149 ignore_set: input.ignore_set,
150 changed_files: input.changed_files,
151 ws_roots: input.ws_roots,
152 top: input.top,
153 codeowners_path: input.codeowners_path,
154 quiet: input.quiet,
155 output: input.output,
156 },
157 )
158}
159
160fn build_health_scope_inputs<'a>(
166 opts: &HealthOptions<'a>,
167 config: &fallow_config::ResolvedConfig,
168 files: &[fallow_types::discover::DiscoveredFile],
169) -> Result<HealthScopeInputs<'a, OwnershipResolver>, ExitCode> {
170 let changed_files = opts
171 .changed_since
172 .and_then(|git_ref| crate::requests::resolve_changed_since(opts.root, git_ref));
173 crate::requests::measure_changed_since_scope(files);
174 let diff_index = health_diff_index(opts);
175 let mut ws_roots = resolve_workspace_scope(
176 opts.root,
177 opts.workspace,
178 opts.changed_workspaces,
179 opts.output,
180 )?;
181 if let Some(scope) = opts.scope.as_ref() {
182 ws_roots.get_or_insert_with(Vec::new).push(scope.clone());
183 }
184 let group_resolver = build_health_group_resolver(opts, config)?;
185 Ok(HealthScopeInputs {
186 changed_files,
187 diff_index,
188 ws_roots,
189 group_resolver,
190 })
191}
192
193fn health_err_to_exit(error: HealthError, output: OutputFormat) -> ExitCode {
198 match error {
199 HealthError::Message { message, exit_code } => emit_error(&message, exit_code, output),
200 HealthError::Printed(code) => ExitCode::from(code),
201 }
202}
203
204pub fn load_health_config(
207 opts: &HealthOptions<'_>,
208) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
209 if let Some(code) = crate::baseline_gate::refuse_save_before_analysis(
210 opts.save_baseline,
211 fallow_engine::baseline::BaselineKind::Health,
212 opts.output,
213 ) {
214 return Err(code);
215 }
216 fallow_engine::health::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
217 .map_err(|e| emit_error(&e, 2, opts.output))?;
218 validate_health_churn_file(opts).map_err(|e| health_err_to_exit(e, opts.output))?;
219 let t = Instant::now();
220 let mut config = crate::load_config_for_analysis(
221 opts.root,
222 opts.config_path,
223 crate::ConfigLoadOptions {
224 output: opts.output,
225 no_cache: opts.no_cache,
226 threads: opts.threads,
227 production_override:
228 fallow_engine::project_config::ProductionFlags::single_analysis_override(
229 opts.production,
230 opts.production_override,
231 ),
232 quiet: opts.quiet,
233 allow_remote_extends: opts.allow_remote_extends,
234 },
235 fallow_config::ProductionAnalysis::Health,
236 )?;
237 if opts.gates.fail_on_parse_error {
238 config.fail_on_parse_error = true;
239 }
240 let config_ms = t.elapsed().as_secs_f64() * 1000.0;
241 Ok((config, config_ms))
242}
243
244pub fn execute_health_with_shared_parse(
250 opts: &HealthOptions<'_>,
251 shared: HealthSharedParseData,
252 pre_computed_duplication: Option<fallow_engine::duplicates::DuplicationReport>,
253) -> Result<HealthResult, ExitCode> {
254 let (config, config_ms) = load_health_config(opts)?;
255 let scope_inputs = build_health_scope_inputs(opts, &config, &shared.files)?;
256 let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
257 let workspaces = shared.workspaces;
258 let seams = health_seams();
259 let result = execute_health_inner(
260 opts,
261 HealthPipelineInputs {
262 config,
263 files: shared.files,
264 modules: shared.modules,
265 config_ms,
266 discover_ms: 0.0,
267 parse_ms: 0.0,
268 parse_cpu_ms: 0.0,
269 shared_parse: true,
270 pre_computed_analysis: shared.analysis_output,
271 dead_code_results: shared.dead_code_results,
272 styling_artifacts: None,
273 pre_computed_duplication,
274 workspaces,
275 workspace_diagnostics,
276 },
277 scope_inputs,
278 &seams,
279 )
280 .map_err(|e| health_err_to_exit(e, opts.output))?;
281 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
282 Ok(result)
283}
284
285pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
286 let (config, config_ms) = load_health_config(opts)?;
287 execute_health_with_config(opts, config, config_ms)
288}
289
290pub fn execute_health_with_config(
291 opts: &HealthOptions<'_>,
292 config: fallow_config::ResolvedConfig,
293 config_ms: f64,
294) -> Result<HealthResult, ExitCode> {
295 let seams = health_seams();
296 let result = execute_health_with_config_and_seams(opts, config, config_ms, &seams)?;
297 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
298 Ok(result)
299}
300
301fn execute_health_with_config_and_seams(
302 opts: &HealthOptions<'_>,
303 config: fallow_config::ResolvedConfig,
304 config_ms: f64,
305 seams: &HealthSeams<'_>,
306) -> Result<HealthResult, ExitCode> {
307 let t = Instant::now();
308 let session = fallow_engine::session::AnalysisSession::from_resolved_config(config)
309 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
310 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
311 let parts = session.parsed_parts_uncached(true);
312 let pre_computed_analysis =
313 fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
314 .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
315 .transpose()
316 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
317 let config = parts.config;
318 let files = parts.files;
319 let modules = parts.modules;
320 let workspaces = parts.workspaces;
321 let workspace_diagnostics = if pre_computed_analysis.is_some() {
322 session.current_workspace_diagnostics()
323 } else {
324 parts.workspace_diagnostics
325 };
326 let parse_ms = parts.parse_ms;
327 let parse_cpu_ms = parts.parse_cpu_ms;
328
329 let scope_inputs = build_health_scope_inputs(opts, &config, &files)?;
330 execute_health_inner(
331 opts,
332 HealthPipelineInputs {
333 config,
334 files,
335 modules,
336 config_ms,
337 discover_ms,
338 parse_ms,
339 parse_cpu_ms,
340 shared_parse: false,
341 dead_code_results: None,
342 styling_artifacts: None,
343 pre_computed_analysis,
344 pre_computed_duplication: None,
345 workspaces,
346 workspace_diagnostics,
347 },
348 scope_inputs,
349 seams,
350 )
351 .map_err(|e| health_err_to_exit(e, opts.output))
352}
353
354pub fn benchmark_execute_health_with_response(
355 opts: &HealthOptions<'_>,
356 response_bytes: &[u8],
357 request_len: &std::cell::Cell<usize>,
358) -> Result<HealthResult, ExitCode> {
359 let analyzer = |options: &fallow_engine::health::RuntimeCoverageOptions,
360 input: RuntimeCoverageSeamInput<'_>| {
361 coverage::analyze_with_transport(
362 options,
363 &coverage::RuntimeCoverageAnalysisInput {
364 root: input.root,
365 modules: input.modules,
366 analysis_output: input.analysis_output,
367 istanbul_coverage: input.istanbul_coverage,
368 file_paths: input.file_paths,
369 ignore_set: input.ignore_set,
370 changed_files: input.changed_files,
371 ws_roots: input.ws_roots,
372 top: input.top,
373 codeowners_path: input.codeowners_path,
374 quiet: input.quiet,
375 output: input.output,
376 },
377 |request, _quiet, output| {
378 let (response, len) =
379 coverage::in_process_response_transport(request, response_bytes, output)?;
380 request_len.set(len);
381 Ok(response)
382 },
383 )
384 };
385 let seams = HealthSeams {
386 runtime_coverage_analyzer: &analyzer,
387 note_graph_structure: &|_module_count, _edge_count| {},
388 };
389 let (config, config_ms) = load_health_config(opts)?;
390 execute_health_with_config_and_seams(opts, config, config_ms, &seams)
391}
392
393pub fn run_health(
394 opts: &HealthOptions<'_>,
395 json_style: crate::json_style::JsonStyle,
396 type_aware: &TypeAwareHealthOptions<'_>,
397) -> ExitCode {
398 let (config, config_ms) = match load_health_config(opts) {
399 Ok(config) => config,
400 Err(code) => return code,
401 };
402 let resolved_type_aware = match resolve_type_aware_health_options(type_aware, &config) {
403 Ok(options) => options,
404 Err(message) => return emit_error(&message, 2, opts.output),
405 };
406 let requested = type_aware.requested || (type_aware.unfiltered && resolved_type_aware.enabled);
407 let mut degraded_meta = None;
408 let semantic = if requested {
409 let enabled = resolved_type_aware.enabled;
410 if !enabled {
411 return emit_error(
412 "--type-coupling requires --type-aware or typeAware.enabled in config",
413 2,
414 opts.output,
415 );
416 }
417 let projects = resolved_type_aware.projects;
418 let require = resolved_type_aware.require;
419 match fallow_api::analyze_type_coupling(opts.root, &projects, &[]) {
420 Ok(outcome) => Some(outcome),
421 Err(error) => {
422 match crate::type_aware_degrade::degrade_or_fail(
423 &crate::type_aware_degrade::DegradeContext {
424 root: opts.root,
425 error: &error.to_string(),
426 failure_label: "Type-aware coupling failed",
427 require,
428 quiet: opts.quiet,
429 output: opts.output,
430 },
431 ) {
432 Ok(meta) => degraded_meta = Some(meta),
433 Err(code) => return code,
434 }
435 None
436 }
437 }
438 } else {
439 None
440 };
441 let mut execution_opts = opts.clone();
442 if let Some(identity) = semantic
443 .as_ref()
444 .and_then(|outcome| outcome.type_aware.meta.identity.clone())
445 {
446 execution_opts.analysis_identity = identity;
447 }
448 let mut result = match execute_health_with_config(&execution_opts, config, config_ms) {
449 Ok(result) => result,
450 Err(code) => return code,
451 };
452 let required_completeness = resolved_type_aware.require.into();
455 result.type_aware_meta = semantic
456 .map(|outcome| {
457 let mut meta = outcome.type_aware.meta;
458 meta.required_completeness = Some(required_completeness);
459 meta
460 })
461 .or(degraded_meta);
462 if let Some(ref timings) = result.timings {
463 report::print_health_performance(timings, opts.output, json_style);
464 }
465 report_loaded_baseline(&mut result, opts.baseline);
466 if let Some(grouping) = result.grouping.as_ref() {
467 crate::requests::record_group_filter(grouping, opts.quiet);
468 }
469 let gates = fallow_engine::health::HealthGateOptions {
472 fail_on_parse_error: result.config.fail_on_parse_error,
473 ..opts.gates
474 };
475 print_health_result(
476 &result,
477 HealthPrintOptions {
478 quiet: opts.quiet,
479 explain: opts.explain,
480 gates,
481 baseline_path: opts.baseline,
482 summary: opts.summary,
483 summary_heading: true,
484 show_explain_tip: true,
485 type_aware_scope: None,
486 skip_score_and_trend: false,
487 css_requested: opts.css,
488 json_style,
489 exit_reason: true,
490 },
491 )
492}
493
494pub struct ResolvedTypeAwareHealthOptions {
495 pub enabled: bool,
496 pub projects: Vec<std::path::PathBuf>,
497 pub require: fallow_config::TypeAwareRequire,
498}
499
500pub fn resolve_type_aware_health_options(
501 options: &TypeAwareHealthOptions<'_>,
502 config: &fallow_config::ResolvedConfig,
503) -> Result<ResolvedTypeAwareHealthOptions, String> {
504 let env_enabled = std::env::var("FALLOW_TYPE_AWARE")
505 .ok()
506 .map(|value| match value.trim().to_ascii_lowercase().as_str() {
507 "1" | "true" | "yes" | "on" => Ok(true),
508 "0" | "false" | "no" | "off" => Ok(false),
509 _ => Err(
510 "FALLOW_TYPE_AWARE must be one of true, false, 1, 0, yes, no, on, or off"
511 .to_string(),
512 ),
513 })
514 .transpose()?;
515 let enabled = options
516 .enabled
517 .or(env_enabled)
518 .unwrap_or(config.type_aware.enabled);
519 let projects = if !options.projects.is_empty() {
520 options.projects.to_vec()
521 } else if let Some(value) = std::env::var_os("FALLOW_TYPE_AWARE_PROJECTS") {
522 std::env::split_paths(&value).collect()
523 } else {
524 config
525 .type_aware
526 .projects
527 .iter()
528 .map(std::path::PathBuf::from)
529 .collect()
530 };
531 let require = if let Some(require) = options.require {
532 require
533 } else if let Ok(value) = std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
534 match value.trim().to_ascii_lowercase().as_str() {
535 "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
536 "complete" => fallow_config::TypeAwareRequire::Complete,
537 _ => {
538 return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete".to_string());
539 }
540 }
541 } else {
542 config.type_aware.require
543 };
544 Ok(ResolvedTypeAwareHealthOptions {
545 enabled,
546 projects,
547 require,
548 })
549}
550
551pub type HealthResult =
553 fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
554
555#[derive(Clone, Copy)]
576pub struct HealthPrintOptions<'a> {
577 pub quiet: bool,
578 pub explain: bool,
579 pub gates: HealthGateOptions,
580 pub baseline_path: Option<&'a std::path::Path>,
584 pub summary: bool,
585 pub summary_heading: bool,
586 pub show_explain_tip: bool,
587 pub type_aware_scope: Option<&'static str>,
588 pub skip_score_and_trend: bool,
589 pub css_requested: bool,
593 pub json_style: crate::json_style::JsonStyle,
594 pub exit_reason: bool,
598}
599
600pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions<'_>) -> ExitCode {
601 let ctx = health_report_context(result, options);
602 let report_code = report::print_health_report(
603 &result.report,
604 result.grouping.as_ref(),
605 result.group_resolver.as_ref(),
606 &ctx,
607 result.config.output,
608 );
609 if report_code != ExitCode::SUCCESS {
610 return report_code;
611 }
612
613 let type_aware_code = crate::exit_codes::gate_failed_exit_code(
616 fallow_output::GateName::TypeAwareRequire,
617 type_aware_completeness_incomplete(result),
618 );
619 let code = if options.gates.report_only {
620 note_stale_baseline_gate_stood_down(result, options);
621 type_aware_code
622 } else {
623 health_exit_code(result, options).max(type_aware_code)
624 };
625 if code == 0 {
626 if !options.gates.report_only {
627 maybe_print_score_gate_note(result, options);
628 }
629 } else if options.exit_reason {
630 crate::gates::print_exit_reason(&crate::gates::ExitReason {
631 gates: health_gate_outcomes(result, options).as_ref(),
632 code,
633 fail_on_issues: options.gates.fail_on_issues,
634 quiet: options.quiet,
635 output: result.config.output,
636 own_lines: &[],
637 });
638 }
639 crate::exit_codes::run_exit_code([code])
640}
641
642pub fn type_aware_completeness_incomplete(result: &HealthResult) -> bool {
645 crate::report::ci::required_type_aware_incomplete(result.type_aware_meta.as_ref())
646}
647
648fn health_report_context<'a>(
649 result: &'a HealthResult,
650 options: HealthPrintOptions<'a>,
651) -> report::ReportContext<'a> {
652 report::ReportContext {
653 package_baselines: &[],
654 root: &result.config.root,
655 rules: &result.config.rules,
656 workspace_diagnostics: &result.workspace_diagnostics,
657 elapsed: result.elapsed,
658 quiet: options.quiet,
659 explain: options.explain,
660 type_aware: result.type_aware_meta.as_ref(),
661 type_aware_scope: options.type_aware_scope,
662 group_by: None,
663 top: None,
664 summary: options.summary,
665 summary_heading: options.summary_heading,
666 show_explain_tip: options.show_explain_tip,
667 baseline_matched: None,
668 baseline_staleness: None,
669 finding_id_query: None,
670 gate_outcomes: health_gate_outcomes(result, options),
671 config_fixable: false,
672 failed_parse_files: 0,
673 skip_score_and_trend: options.skip_score_and_trend,
674 css_requested: options.css_requested,
675 json_style: options.json_style,
676 include_fragments: true,
677 }
678}
679
680fn health_gate_outcomes(
683 result: &HealthResult,
684 options: HealthPrintOptions<'_>,
685) -> Option<fallow_output::GateOutcomes> {
686 crate::gates::health_gate_outcomes(&crate::gates::HealthGateInputs {
687 report_only: options.gates.report_only,
688 min_score: options.gates.min_score.map(|threshold| {
689 (
690 threshold,
691 result.report.health_score.as_ref().map(|score| score.score),
692 )
693 }),
694 min_severity: options.gates.min_severity.map(|floor| {
695 (
696 floor,
697 blocking_findings(result)
698 .filter(|finding| finding.severity >= floor)
699 .count(),
700 )
701 }),
702 coverage_gaps: result
703 .should_fail_on_coverage_gaps
704 .then_some(result.coverage_gaps_has_findings),
705 runtime_coverage: result
706 .report
707 .runtime_coverage
708 .is_some()
709 .then(|| has_failing_runtime_coverage(result)),
710 baseline_staleness: result.report.summary.baseline_staleness.as_ref(),
711 fail_on_stale_baseline: options.gates.fail_on_stale_baseline,
712 blocking_findings: blocking_findings(result).count(),
713 type_aware_meta: result.type_aware_meta.as_ref(),
714 parse_error: health_parse_error_outcome(result, options),
715 })
716}
717
718fn health_parse_error_outcome(
722 result: &HealthResult,
723 options: HealthPrintOptions<'_>,
724) -> Option<fallow_output::GateOutcome> {
725 crate::gates::parse_error_outcome(
726 options.gates.fail_on_parse_error,
727 true,
728 &crate::gates::parse_degraded_files(&result.config.root, &result.workspace_diagnostics),
729 )
730}
731
732fn health_exit_code(result: &HealthResult, options: HealthPrintOptions<'_>) -> u8 {
742 use crate::exit_codes::gate_failed_exit_code;
743 use fallow_output::GateName;
744
745 let findings_gate = if options.gates.min_severity.is_some() {
746 GateName::HealthMinSeverity
747 } else {
748 GateName::HealthFindings
749 };
750 let parse_error = health_parse_error_outcome(result, options);
751 let parse_error_failed = parse_error
752 .as_ref()
753 .is_some_and(fallow_output::GateOutcome::fails_run);
754 if let Some(outcome) = parse_error.as_ref().filter(|_| parse_error_failed) {
755 crate::gates::print_parse_error_gate_failure(&outcome.files);
756 }
757 [
758 gate_failed_exit_code(GateName::HealthMinScore, score_gate_failed(result, options)),
759 gate_failed_exit_code(findings_gate, findings_gate_failed(result, options)),
760 gate_failed_exit_code(
761 GateName::HealthRuntimeCoverage,
762 has_failing_runtime_coverage(result),
763 ),
764 gate_failed_exit_code(
765 GateName::StaleBaseline,
766 stale_baseline_gate_failed(result, options),
767 ),
768 gate_failed_exit_code(
769 GateName::HealthCoverageGaps,
770 result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings,
771 ),
772 gate_failed_exit_code(GateName::ParseError, parse_error_failed),
773 ]
774 .into_iter()
775 .max()
776 .unwrap_or(0)
777}
778
779fn report_loaded_baseline(result: &mut HealthResult, baseline_path: Option<&std::path::Path>) {
790 let Some(path) = baseline_path else {
791 return;
792 };
793 note_unrecognised_health_baseline(result, baseline_path, "--baseline");
794 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
795 return;
796 };
797 crate::output_runtime::set_loaded_baseline(crate::output_runtime::LoadedBaselineRecheck {
798 command: "health",
799 path: path.display().to_string(),
800 baseline_entries: staleness.baseline_entries,
801 scope_reasons: staleness.scope_reasons,
802 });
803}
804
805pub fn note_unrecognised_health_baseline(
822 result: &mut HealthResult,
823 baseline_path: Option<&std::path::Path>,
824 flag: &str,
825) {
826 let Some(path) = baseline_path else {
827 return;
828 };
829 let Some(staleness) = result.report.summary.baseline_staleness.as_mut() else {
830 return;
831 };
832 if !staleness.unrecognised_format {
833 return;
834 }
835 let saved_by = saved_by_another_command(path);
836 staleness.saved_by = saved_by.map(fallow_engine::baseline::BaselineKind::as_str);
837 crate::baseline_gate::note_unrecognised_baseline(
838 Some(path),
839 true,
840 saved_by,
841 fallow_engine::baseline::BaselineKind::Health,
842 flag,
843 );
844}
845
846fn saved_by_another_command(
850 path: &std::path::Path,
851) -> Option<fallow_engine::baseline::BaselineKind> {
852 let content = std::fs::read_to_string(path).ok()?;
853 fallow_engine::baseline::classify_baseline_file(
854 &content,
855 fallow_engine::baseline::BaselineKind::Health,
856 )
857 .saved_by()
858}
859
860fn note_stale_baseline_gate_stood_down(result: &HealthResult, options: HealthPrintOptions<'_>) {
866 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
867 return;
868 };
869 if staleness.baseline_entries == 0 && !staleness.unrecognised_format {
874 return;
875 }
876 crate::baseline_gate::note_stood_down(
877 options.baseline_path,
878 options.gates.fail_on_stale_baseline,
879 "--report-only never fails a run",
880 );
881}
882
883fn stale_baseline_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
886 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
887 return false;
888 };
889 crate::baseline_gate::gate_failed_from_envelope(
890 staleness,
891 options.baseline_path,
892 options.gates.fail_on_stale_baseline,
893 fallow_engine::baseline::BaselineKind::Health,
894 )
895}
896
897fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
898 let Some(threshold) = options.gates.min_score else {
899 return false;
900 };
901 let Some(ref hs) = result.report.health_score else {
902 return false;
903 };
904 if hs.score >= threshold {
905 return false;
906 }
907
908 if !options.quiet && matches!(result.config.output, fallow_config::OutputFormat::Human) {
911 eprintln!(
912 "Health score {:.1} ({}) is below minimum threshold {:.0}",
913 hs.score, hs.grade, threshold
914 );
915 }
916 true
917}
918
919fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
920 if let Some(min_sev) = options.gates.min_severity {
921 blocking_findings(result).any(|f| f.severity >= min_sev)
922 } else if options.gates.min_score.is_none() {
923 blocking_findings(result).next().is_some()
924 } else {
925 false
926 }
927}
928
929fn blocking_findings(result: &HealthResult) -> impl Iterator<Item = &fallow_output::HealthFinding> {
934 result
935 .report
936 .findings
937 .iter()
938 .filter(|finding| finding.blocks())
939}
940
941fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
942 result
943 .report
944 .runtime_coverage
945 .as_ref()
946 .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
947}
948
949fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
950 matches!(
951 finding.verdict,
952 fallow_output::RuntimeCoverageVerdict::SafeToDelete
953 | fallow_output::RuntimeCoverageVerdict::ReviewRequired
954 | fallow_output::RuntimeCoverageVerdict::LowTraffic
955 )
956}
957
958fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions<'_>) {
959 if options.gates.min_score.is_none()
960 || options.gates.min_severity.is_some()
961 || options.quiet
962 || result.report.findings.is_empty()
963 || !matches!(result.config.output, OutputFormat::Human)
964 {
965 return;
966 }
967
968 {
969 eprintln!(
970 "{}",
971 "Findings above are informational: --min-score gates on the score, not on findings."
972 .dimmed()
973 );
974 }
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980 use fallow_config::{FallowConfig, OutputFormat};
981 use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
982 use std::path::PathBuf;
983 use std::time::Duration;
984
985 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
986 ComplexityViolation {
987 path: PathBuf::from("/project/src/a.ts"),
988 name: name.to_string(),
989 line: 1,
990 col: 0,
991 cyclomatic: match exceeded {
992 ExceededThreshold::Cyclomatic
993 | ExceededThreshold::Both
994 | ExceededThreshold::CyclomaticCrap
995 | ExceededThreshold::All => 25,
996 _ => 8,
997 },
998 cognitive: match exceeded {
999 ExceededThreshold::Cognitive
1000 | ExceededThreshold::Both
1001 | ExceededThreshold::CognitiveCrap
1002 | ExceededThreshold::All => 20,
1003 _ => 5,
1004 },
1005 line_count: 10,
1006 param_count: 0,
1007 react_hook_count: 0,
1008 react_jsx_max_depth: 0,
1009 react_prop_count: 0,
1010 react_hook_profile: None,
1011 exceeded,
1012 effective_severity: None,
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 changed_files_analyzed: None,
1126 }
1127 }
1128
1129 fn moderate_finding() -> fallow_output::HealthFinding {
1130 make_finding("moderate", ExceededThreshold::Cyclomatic).into()
1131 }
1132
1133 fn critical_finding() -> fallow_output::HealthFinding {
1134 let mut v = make_finding("critical", ExceededThreshold::All);
1135 v.severity = FindingSeverity::Critical;
1136 v.into()
1137 }
1138
1139 fn gate_exit(
1141 result: &HealthResult,
1142 min_score: Option<f64>,
1143 min_severity: Option<FindingSeverity>,
1144 report_only: bool,
1145 ) -> ExitCode {
1146 print_health_result(
1147 result,
1148 HealthPrintOptions {
1149 quiet: true,
1150 explain: false,
1151 gates: HealthGateOptions {
1152 min_score,
1153 min_severity,
1154 report_only,
1155 fail_on_stale_baseline: false,
1156 fail_on_parse_error: false,
1157 fail_on_issues: false,
1158 },
1159 baseline_path: None,
1160 summary: false,
1161 summary_heading: true,
1162 show_explain_tip: true,
1163 type_aware_scope: None,
1164 skip_score_and_trend: false,
1165 css_requested: false,
1166 json_style: crate::json_style::JsonStyle::Compact,
1167 exit_reason: true,
1168 },
1169 )
1170 }
1171
1172 #[test]
1173 fn plain_health_with_findings_fails() {
1174 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1175 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1176 }
1177
1178 #[test]
1179 fn plain_health_with_no_findings_succeeds() {
1180 let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1181 assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
1182 }
1183
1184 #[test]
1185 fn min_score_zero_never_fails_even_with_findings() {
1186 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1187 assert_eq!(
1188 gate_exit(&result, Some(0.0), None, false),
1189 ExitCode::SUCCESS
1190 );
1191 }
1192
1193 #[test]
1194 fn min_score_passing_demotes_findings_to_informational() {
1195 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1196 assert_eq!(
1197 gate_exit(&result, Some(80.0), None, false),
1198 ExitCode::SUCCESS
1199 );
1200 }
1201
1202 #[test]
1203 fn min_score_below_threshold_fails() {
1204 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1205 assert_eq!(
1206 gate_exit(&result, Some(80.0), None, false),
1207 ExitCode::from(1)
1208 );
1209 }
1210
1211 #[test]
1212 fn min_severity_gates_on_severity_independent_of_min_score() {
1213 let only_moderate =
1214 fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1215 assert_eq!(
1216 gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
1217 ExitCode::SUCCESS,
1218 );
1219 let with_critical = fx_gate_result(
1220 vec![moderate_finding(), critical_finding()],
1221 Some(fx_health_score(87.5, "A")),
1222 );
1223 assert_eq!(
1224 gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
1225 ExitCode::from(1),
1226 );
1227 }
1228
1229 #[test]
1230 fn min_score_and_min_severity_compose_as_or() {
1231 let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1232 assert_eq!(
1233 gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
1234 ExitCode::SUCCESS,
1235 );
1236 let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1237 assert_eq!(
1238 gate_exit(
1239 &low_score,
1240 Some(80.0),
1241 Some(FindingSeverity::Critical),
1242 false
1243 ),
1244 ExitCode::from(1),
1245 );
1246 let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
1247 assert_eq!(
1248 gate_exit(
1249 &critical,
1250 Some(80.0),
1251 Some(FindingSeverity::Critical),
1252 false
1253 ),
1254 ExitCode::from(1),
1255 );
1256 }
1257
1258 #[test]
1259 fn report_only_never_fails_on_findings_or_low_score() {
1260 let result = fx_gate_result(
1261 vec![moderate_finding(), critical_finding()],
1262 Some(fx_health_score(10.0, "F")),
1263 );
1264 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1265 }
1266
1267 #[test]
1268 fn runtime_coverage_gate_independent_of_min_score() {
1269 let result = fx_low_traffic_runtime_result();
1270 assert_eq!(
1271 gate_exit(&result, Some(0.0), None, false),
1272 ExitCode::from(1)
1273 );
1274 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1275 }
1276
1277 fn fx_low_traffic_runtime_result() -> HealthResult {
1278 HealthResult {
1279 branching_by_file: fallow_engine::health::BranchingByFile::default(),
1280 report: fallow_output::HealthReport {
1281 runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
1282 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
1283 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
1284 signals: Vec::new(),
1285 summary: fx_summary(1, 0, 1, 0),
1286 findings: vec![fallow_output::RuntimeCoverageFinding {
1287 id: "fallow:prod:lowtraffic".to_owned(),
1288 stable_id: None,
1289 path: PathBuf::from("/project/src/cold.ts"),
1290 function: "coldPath".to_owned(),
1291 line: 14,
1292 verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
1293 invocations: Some(1),
1294 confidence: fallow_output::RuntimeCoverageConfidence::Low,
1295 evidence: fx_evidence("used", "not_covered", "tracked"),
1296 actions: vec![],
1297 source_hash: None,
1298 discriminators: None,
1299 }],
1300 hot_paths: vec![],
1301 blast_radius: vec![],
1302 importance: vec![],
1303 watermark: None,
1304 warnings: vec![],
1305 actionable: true,
1306 actionability_reason: None,
1307 actionability_verdict: None,
1308 provenance: fallow_output::RuntimeCoverageProvenance::default(),
1309 }),
1310 ..fallow_output::HealthReport::default()
1311 },
1312 grouping: None,
1313 group_resolver: None,
1314 config: test_resolved_config(),
1315 workspace_diagnostics: Vec::new(),
1316 elapsed: Duration::default(),
1317 timings: None,
1318 type_aware_meta: None,
1319 coverage_gaps_has_findings: false,
1320 should_fail_on_coverage_gaps: false,
1321 changed_files_analyzed: None,
1322 }
1323 }
1324
1325 #[test]
1326 fn print_health_result_fails_on_low_traffic_runtime_coverage() {
1327 let result = fx_low_traffic_runtime_result();
1328
1329 assert_eq!(
1330 print_health_result(
1331 &result,
1332 HealthPrintOptions {
1333 quiet: true,
1334 explain: false,
1335 gates: HealthGateOptions::default(),
1336 baseline_path: None,
1337 summary: false,
1338 summary_heading: true,
1339 show_explain_tip: true,
1340 type_aware_scope: None,
1341 skip_score_and_trend: false,
1342 css_requested: false,
1343 json_style: crate::json_style::JsonStyle::Compact,
1344 exit_reason: true,
1345 },
1346 ),
1347 ExitCode::from(1),
1348 );
1349 }
1350
1351 fn incomplete_required_type_aware_meta() -> fallow_types::envelope::TypeAwareMeta {
1354 fallow_types::envelope::TypeAwareMeta {
1355 required_completeness: Some(
1356 fallow_types::semantic::SemanticCompletenessRequirement::Complete,
1357 ),
1358 queries: vec![fallow_types::semantic::SemanticQuerySummary {
1359 query_id: 0,
1360 capability: fallow_types::semantic::SemanticCapability::TypeCoupling,
1361 assertion: "type coupling".to_string(),
1362 status: fallow_types::semantic::SemanticCompleteness::Partial,
1363 reason_code: None,
1364 total_evidence_count: 0,
1365 truncated: false,
1366 omissions: Vec::new(),
1367 actions: Vec::new(),
1368 }],
1369 ..Default::default()
1370 }
1371 }
1372
1373 #[test]
1377 fn a_failed_type_aware_completeness_gate_fails_the_health_print() {
1378 let mut result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1379 result.type_aware_meta = Some(incomplete_required_type_aware_meta());
1380 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1381 assert_eq!(gate_exit(&result, None, None, true), ExitCode::from(1));
1382
1383 let options = HealthPrintOptions {
1384 quiet: true,
1385 explain: false,
1386 gates: HealthGateOptions::default(),
1387 baseline_path: None,
1388 summary: false,
1389 summary_heading: true,
1390 show_explain_tip: true,
1391 type_aware_scope: None,
1392 skip_score_and_trend: false,
1393 css_requested: false,
1394 json_style: crate::json_style::JsonStyle::Compact,
1395 exit_reason: true,
1396 };
1397 let line = crate::report::gate_outcome_text::exit_reason_line(
1398 health_gate_outcomes(&result, options).as_ref(),
1399 1,
1400 false,
1401 &[],
1402 );
1403 assert_eq!(
1404 line.as_deref(),
1405 Some("Exit code 1: gate type-aware-require failed.")
1406 );
1407 }
1408
1409 #[test]
1410 fn the_envelope_states_a_failed_type_aware_completeness_gate() {
1411 let mut result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1412 result.type_aware_meta = Some(incomplete_required_type_aware_meta());
1413 let options = HealthPrintOptions {
1414 quiet: true,
1415 explain: false,
1416 gates: HealthGateOptions::default(),
1417 baseline_path: None,
1418 summary: false,
1419 summary_heading: true,
1420 show_explain_tip: true,
1421 type_aware_scope: None,
1422 skip_score_and_trend: false,
1423 css_requested: false,
1424 json_style: crate::json_style::JsonStyle::Compact,
1425 exit_reason: true,
1426 };
1427 let gates = serde_json::to_value(health_gate_outcomes(&result, options))
1428 .expect("gate outcomes serialize");
1429 assert_eq!(
1430 gates["type-aware-require"],
1431 serde_json::json!({ "status": "fail", "enforced": true }),
1432 "the run exits 1 on this gate, so the envelope must state it: {gates}"
1433 );
1434 }
1435}