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(
248 opts: &HealthOptions<'_>,
249 shared: HealthSharedParseData,
250) -> Result<HealthResult, ExitCode> {
251 let (config, config_ms) = load_health_config(opts)?;
252 let scope_inputs = build_health_scope_inputs(opts, &config, &shared.files)?;
253 let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
254 let workspaces = shared.workspaces;
255 let seams = health_seams();
256 let result = execute_health_inner(
257 opts,
258 HealthPipelineInputs {
259 config,
260 files: shared.files,
261 modules: shared.modules,
262 config_ms,
263 discover_ms: 0.0,
264 parse_ms: 0.0,
265 parse_cpu_ms: 0.0,
266 shared_parse: true,
267 pre_computed_analysis: shared.analysis_output,
268 dead_code_results: shared.dead_code_results,
269 styling_artifacts: None,
270 pre_computed_duplication: None,
271 workspaces,
272 workspace_diagnostics,
273 },
274 scope_inputs,
275 &seams,
276 )
277 .map_err(|e| health_err_to_exit(e, opts.output))?;
278 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
279 Ok(result)
280}
281
282pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
283 let (config, config_ms) = load_health_config(opts)?;
284 execute_health_with_config(opts, config, config_ms)
285}
286
287pub fn execute_health_with_config(
288 opts: &HealthOptions<'_>,
289 config: fallow_config::ResolvedConfig,
290 config_ms: f64,
291) -> Result<HealthResult, ExitCode> {
292 let seams = health_seams();
293 let result = execute_health_with_config_and_seams(opts, config, config_ms, &seams)?;
294 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
295 Ok(result)
296}
297
298fn execute_health_with_config_and_seams(
299 opts: &HealthOptions<'_>,
300 config: fallow_config::ResolvedConfig,
301 config_ms: f64,
302 seams: &HealthSeams<'_>,
303) -> Result<HealthResult, ExitCode> {
304 let t = Instant::now();
305 let session = fallow_engine::session::AnalysisSession::from_resolved_config(config)
306 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
307 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
308 let parts = session.parsed_parts_uncached(true);
309 let pre_computed_analysis =
310 fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
311 .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
312 .transpose()
313 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
314 let config = parts.config;
315 let files = parts.files;
316 let modules = parts.modules;
317 let workspaces = parts.workspaces;
318 let workspace_diagnostics = if pre_computed_analysis.is_some() {
319 session.current_workspace_diagnostics()
320 } else {
321 parts.workspace_diagnostics
322 };
323 let parse_ms = parts.parse_ms;
324 let parse_cpu_ms = parts.parse_cpu_ms;
325
326 let scope_inputs = build_health_scope_inputs(opts, &config, &files)?;
327 execute_health_inner(
328 opts,
329 HealthPipelineInputs {
330 config,
331 files,
332 modules,
333 config_ms,
334 discover_ms,
335 parse_ms,
336 parse_cpu_ms,
337 shared_parse: false,
338 dead_code_results: None,
339 styling_artifacts: None,
340 pre_computed_analysis,
341 pre_computed_duplication: None,
342 workspaces,
343 workspace_diagnostics,
344 },
345 scope_inputs,
346 seams,
347 )
348 .map_err(|e| health_err_to_exit(e, opts.output))
349}
350
351pub fn benchmark_execute_health_with_response(
352 opts: &HealthOptions<'_>,
353 response_bytes: &[u8],
354 request_len: &std::cell::Cell<usize>,
355) -> Result<HealthResult, ExitCode> {
356 let analyzer = |options: &fallow_engine::health::RuntimeCoverageOptions,
357 input: RuntimeCoverageSeamInput<'_>| {
358 coverage::analyze_with_transport(
359 options,
360 &coverage::RuntimeCoverageAnalysisInput {
361 root: input.root,
362 modules: input.modules,
363 analysis_output: input.analysis_output,
364 istanbul_coverage: input.istanbul_coverage,
365 file_paths: input.file_paths,
366 ignore_set: input.ignore_set,
367 changed_files: input.changed_files,
368 ws_roots: input.ws_roots,
369 top: input.top,
370 codeowners_path: input.codeowners_path,
371 quiet: input.quiet,
372 output: input.output,
373 },
374 |request, _quiet, output| {
375 let (response, len) =
376 coverage::in_process_response_transport(request, response_bytes, output)?;
377 request_len.set(len);
378 Ok(response)
379 },
380 )
381 };
382 let seams = HealthSeams {
383 runtime_coverage_analyzer: &analyzer,
384 note_graph_structure: &|_module_count, _edge_count| {},
385 };
386 let (config, config_ms) = load_health_config(opts)?;
387 execute_health_with_config_and_seams(opts, config, config_ms, &seams)
388}
389
390pub fn run_health(
391 opts: &HealthOptions<'_>,
392 json_style: crate::json_style::JsonStyle,
393 type_aware: &TypeAwareHealthOptions<'_>,
394) -> ExitCode {
395 let (config, config_ms) = match load_health_config(opts) {
396 Ok(config) => config,
397 Err(code) => return code,
398 };
399 let resolved_type_aware = match resolve_type_aware_health_options(type_aware, &config) {
400 Ok(options) => options,
401 Err(message) => return emit_error(&message, 2, opts.output),
402 };
403 let requested = type_aware.requested || (type_aware.unfiltered && resolved_type_aware.enabled);
404 let mut degraded_meta = None;
405 let semantic = if requested {
406 let enabled = resolved_type_aware.enabled;
407 if !enabled {
408 return emit_error(
409 "--type-coupling requires --type-aware or typeAware.enabled in config",
410 2,
411 opts.output,
412 );
413 }
414 let projects = resolved_type_aware.projects;
415 let require = resolved_type_aware.require;
416 match fallow_api::analyze_type_coupling(opts.root, &projects, &[]) {
417 Ok(outcome) => Some(outcome),
418 Err(error) => {
419 match crate::type_aware_degrade::degrade_or_fail(
420 &crate::type_aware_degrade::DegradeContext {
421 root: opts.root,
422 error: &error.to_string(),
423 failure_label: "Type-aware coupling failed",
424 require,
425 quiet: opts.quiet,
426 output: opts.output,
427 },
428 ) {
429 Ok(meta) => degraded_meta = Some(meta),
430 Err(code) => return code,
431 }
432 None
433 }
434 }
435 } else {
436 None
437 };
438 let mut execution_opts = opts.clone();
439 if let Some(identity) = semantic
440 .as_ref()
441 .and_then(|outcome| outcome.type_aware.meta.identity.clone())
442 {
443 execution_opts.analysis_identity = identity;
444 }
445 let mut result = match execute_health_with_config(&execution_opts, config, config_ms) {
446 Ok(result) => result,
447 Err(code) => return code,
448 };
449 let required_completeness = resolved_type_aware.require.into();
452 result.type_aware_meta = semantic
453 .map(|outcome| {
454 let mut meta = outcome.type_aware.meta;
455 meta.required_completeness = Some(required_completeness);
456 meta
457 })
458 .or(degraded_meta);
459 if let Some(ref timings) = result.timings {
460 report::print_health_performance(timings, opts.output, json_style);
461 }
462 report_loaded_baseline(&mut result, opts.baseline);
463 let gates = fallow_engine::health::HealthGateOptions {
466 fail_on_parse_error: result.config.fail_on_parse_error,
467 ..opts.gates
468 };
469 let code = print_health_result(
470 &result,
471 HealthPrintOptions {
472 quiet: opts.quiet,
473 explain: opts.explain,
474 gates,
475 baseline_path: opts.baseline,
476 summary: opts.summary,
477 summary_heading: true,
478 show_explain_tip: true,
479 type_aware_scope: None,
480 skip_score_and_trend: false,
481 css_requested: opts.css,
482 json_style,
483 },
484 );
485 if code != ExitCode::SUCCESS {
486 return code;
487 }
488 ExitCode::from(crate::exit_codes::gate_failed_exit_code(
491 fallow_output::GateName::TypeAwareRequire,
492 crate::report::ci::required_type_aware_incomplete(result.type_aware_meta.as_ref()),
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}
597
598pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions<'_>) -> ExitCode {
599 let ctx = health_report_context(result, options);
600 let report_code = report::print_health_report(
601 &result.report,
602 result.grouping.as_ref(),
603 result.group_resolver.as_ref(),
604 &ctx,
605 result.config.output,
606 );
607 if report_code != ExitCode::SUCCESS {
608 return report_code;
609 }
610
611 if options.gates.report_only {
612 note_stale_baseline_gate_stood_down(result, options);
613 return ExitCode::SUCCESS;
614 }
615
616 let code = health_exit_code(result, options);
617 if code == 0 {
618 maybe_print_score_gate_note(result, options);
619 }
620 crate::exit_codes::run_exit_code([code])
621}
622
623fn health_report_context<'a>(
624 result: &'a HealthResult,
625 options: HealthPrintOptions<'a>,
626) -> report::ReportContext<'a> {
627 report::ReportContext {
628 root: &result.config.root,
629 rules: &result.config.rules,
630 workspace_diagnostics: &result.workspace_diagnostics,
631 elapsed: result.elapsed,
632 quiet: options.quiet,
633 explain: options.explain,
634 type_aware: result.type_aware_meta.as_ref(),
635 type_aware_scope: options.type_aware_scope,
636 group_by: None,
637 top: None,
638 summary: options.summary,
639 summary_heading: options.summary_heading,
640 show_explain_tip: options.show_explain_tip,
641 baseline_matched: None,
642 baseline_staleness: None,
643 gate_outcomes: health_gate_outcomes(result, options),
644 config_fixable: false,
645 failed_parse_files: 0,
646 skip_score_and_trend: options.skip_score_and_trend,
647 css_requested: options.css_requested,
648 json_style: options.json_style,
649 include_fragments: true,
650 }
651}
652
653fn health_gate_outcomes(
656 result: &HealthResult,
657 options: HealthPrintOptions<'_>,
658) -> Option<fallow_output::GateOutcomes> {
659 crate::gates::health_gate_outcomes(&crate::gates::HealthGateInputs {
660 report_only: options.gates.report_only,
661 min_score: options.gates.min_score.map(|threshold| {
662 (
663 threshold,
664 result.report.health_score.as_ref().map(|score| score.score),
665 )
666 }),
667 min_severity: options.gates.min_severity.map(|floor| {
668 (
669 floor,
670 blocking_findings(result)
671 .filter(|finding| finding.severity >= floor)
672 .count(),
673 )
674 }),
675 coverage_gaps: result
676 .should_fail_on_coverage_gaps
677 .then_some(result.coverage_gaps_has_findings),
678 runtime_coverage: result
679 .report
680 .runtime_coverage
681 .is_some()
682 .then(|| has_failing_runtime_coverage(result)),
683 baseline_staleness: result.report.summary.baseline_staleness.as_ref(),
684 fail_on_stale_baseline: options.gates.fail_on_stale_baseline,
685 has_findings: blocking_findings(result).next().is_some(),
686 type_aware_meta: result.type_aware_meta.as_ref(),
687 parse_error: health_parse_error_outcome(result, options),
688 })
689}
690
691fn health_parse_error_outcome(
695 result: &HealthResult,
696 options: HealthPrintOptions<'_>,
697) -> Option<fallow_output::GateOutcome> {
698 crate::gates::parse_error_outcome(
699 options.gates.fail_on_parse_error,
700 true,
701 &crate::gates::parse_degraded_files(&result.config.root, &result.workspace_diagnostics),
702 )
703}
704
705fn health_exit_code(result: &HealthResult, options: HealthPrintOptions<'_>) -> u8 {
714 use crate::exit_codes::gate_failed_exit_code;
715 use fallow_output::GateName;
716
717 let findings_gate = if options.gates.min_severity.is_some() {
718 GateName::HealthMinSeverity
719 } else {
720 GateName::HealthFindings
721 };
722 let parse_error = health_parse_error_outcome(result, options);
723 let parse_error_failed = parse_error
724 .as_ref()
725 .is_some_and(fallow_output::GateOutcome::fails_run);
726 if let Some(outcome) = parse_error.as_ref().filter(|_| parse_error_failed) {
727 crate::gates::print_parse_error_gate_failure(&outcome.files);
728 }
729 [
730 gate_failed_exit_code(GateName::HealthMinScore, score_gate_failed(result, options)),
731 gate_failed_exit_code(findings_gate, findings_gate_failed(result, options)),
732 gate_failed_exit_code(
733 GateName::HealthRuntimeCoverage,
734 has_failing_runtime_coverage(result),
735 ),
736 gate_failed_exit_code(
737 GateName::StaleBaseline,
738 stale_baseline_gate_failed(result, options),
739 ),
740 gate_failed_exit_code(
741 GateName::HealthCoverageGaps,
742 result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings,
743 ),
744 gate_failed_exit_code(GateName::ParseError, parse_error_failed),
745 ]
746 .into_iter()
747 .max()
748 .unwrap_or(0)
749}
750
751fn report_loaded_baseline(result: &mut HealthResult, baseline_path: Option<&std::path::Path>) {
762 let Some(path) = baseline_path else {
763 return;
764 };
765 note_unrecognised_health_baseline(result, baseline_path, "--baseline");
766 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
767 return;
768 };
769 crate::output_runtime::set_loaded_baseline(crate::output_runtime::LoadedBaselineRecheck {
770 command: "health",
771 path: path.display().to_string(),
772 baseline_entries: staleness.baseline_entries,
773 scope_reasons: staleness.scope_reasons,
774 });
775}
776
777pub fn note_unrecognised_health_baseline(
794 result: &mut HealthResult,
795 baseline_path: Option<&std::path::Path>,
796 flag: &str,
797) {
798 let Some(path) = baseline_path else {
799 return;
800 };
801 let Some(staleness) = result.report.summary.baseline_staleness.as_mut() else {
802 return;
803 };
804 if !staleness.unrecognised_format {
805 return;
806 }
807 let saved_by = saved_by_another_command(path);
808 staleness.saved_by = saved_by.map(fallow_engine::baseline::BaselineKind::as_str);
809 crate::baseline_gate::note_unrecognised_baseline(
810 Some(path),
811 true,
812 saved_by,
813 fallow_engine::baseline::BaselineKind::Health,
814 flag,
815 );
816}
817
818fn saved_by_another_command(
822 path: &std::path::Path,
823) -> Option<fallow_engine::baseline::BaselineKind> {
824 let content = std::fs::read_to_string(path).ok()?;
825 fallow_engine::baseline::classify_baseline_file(
826 &content,
827 fallow_engine::baseline::BaselineKind::Health,
828 )
829 .saved_by()
830}
831
832fn note_stale_baseline_gate_stood_down(result: &HealthResult, options: HealthPrintOptions<'_>) {
838 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
839 return;
840 };
841 if staleness.baseline_entries == 0 && !staleness.unrecognised_format {
846 return;
847 }
848 crate::baseline_gate::note_stood_down(
849 options.baseline_path,
850 options.gates.fail_on_stale_baseline,
851 "--report-only never fails a run",
852 );
853}
854
855fn stale_baseline_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
858 let Some(staleness) = result.report.summary.baseline_staleness.as_ref() else {
859 return false;
860 };
861 crate::baseline_gate::gate_failed_from_envelope(
862 staleness,
863 options.baseline_path,
864 options.gates.fail_on_stale_baseline,
865 fallow_engine::baseline::BaselineKind::Health,
866 )
867}
868
869fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
870 let Some(threshold) = options.gates.min_score else {
871 return false;
872 };
873 let Some(ref hs) = result.report.health_score else {
874 return false;
875 };
876 if hs.score >= threshold {
877 return false;
878 }
879
880 if !options.quiet {
881 eprintln!(
882 "Health score {:.1} ({}) is below minimum threshold {:.0}",
883 hs.score, hs.grade, threshold
884 );
885 }
886 true
887}
888
889fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions<'_>) -> bool {
890 if let Some(min_sev) = options.gates.min_severity {
891 blocking_findings(result).any(|f| f.severity >= min_sev)
892 } else if options.gates.min_score.is_none() {
893 blocking_findings(result).next().is_some()
894 } else {
895 false
896 }
897}
898
899fn blocking_findings(result: &HealthResult) -> impl Iterator<Item = &fallow_output::HealthFinding> {
904 result
905 .report
906 .findings
907 .iter()
908 .filter(|finding| finding.blocks())
909}
910
911fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
912 result
913 .report
914 .runtime_coverage
915 .as_ref()
916 .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
917}
918
919fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
920 matches!(
921 finding.verdict,
922 fallow_output::RuntimeCoverageVerdict::SafeToDelete
923 | fallow_output::RuntimeCoverageVerdict::ReviewRequired
924 | fallow_output::RuntimeCoverageVerdict::LowTraffic
925 )
926}
927
928fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions<'_>) {
929 if options.gates.min_score.is_none()
930 || options.gates.min_severity.is_some()
931 || options.quiet
932 || result.report.findings.is_empty()
933 || !matches!(result.config.output, OutputFormat::Human)
934 {
935 return;
936 }
937
938 {
939 eprintln!(
940 "{}",
941 "Findings above are informational: --min-score gates on the score, not on findings."
942 .dimmed()
943 );
944 }
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use fallow_config::{FallowConfig, OutputFormat};
951 use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
952 use std::path::PathBuf;
953 use std::time::Duration;
954
955 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
956 ComplexityViolation {
957 path: PathBuf::from("/project/src/a.ts"),
958 name: name.to_string(),
959 line: 1,
960 col: 0,
961 cyclomatic: match exceeded {
962 ExceededThreshold::Cyclomatic
963 | ExceededThreshold::Both
964 | ExceededThreshold::CyclomaticCrap
965 | ExceededThreshold::All => 25,
966 _ => 8,
967 },
968 cognitive: match exceeded {
969 ExceededThreshold::Cognitive
970 | ExceededThreshold::Both
971 | ExceededThreshold::CognitiveCrap
972 | ExceededThreshold::All => 20,
973 _ => 5,
974 },
975 line_count: 10,
976 param_count: 0,
977 react_hook_count: 0,
978 react_jsx_max_depth: 0,
979 react_prop_count: 0,
980 react_hook_profile: None,
981 exceeded,
982 effective_severity: None,
983 severity: FindingSeverity::Moderate,
984 crap: exceeded.includes_crap().then_some(30.0),
985 coverage_pct: None,
986 coverage_tier: None,
987 coverage_source: None,
988 inherited_from: None,
989 component_rollup: None,
990 contributions: Vec::new(),
991 effective_thresholds: None,
992 threshold_source: None,
993 }
994 }
995
996 fn test_resolved_config() -> fallow_config::ResolvedConfig {
997 FallowConfig::default().resolve(
998 PathBuf::from("/project"),
999 OutputFormat::Json,
1000 1,
1001 true,
1002 true,
1003 None,
1004 )
1005 }
1006
1007 fn fx_summary(
1008 tracked: usize,
1009 hit: usize,
1010 unhit: usize,
1011 untracked: usize,
1012 ) -> fallow_output::RuntimeCoverageSummary {
1013 #[expect(
1014 clippy::cast_precision_loss,
1015 reason = "test fixture totals are tiny, f64 precision is fine"
1016 )]
1017 let coverage_percent = if tracked == 0 {
1018 0.0
1019 } else {
1020 (hit as f64 / tracked as f64) * 100.0
1021 };
1022 fallow_output::RuntimeCoverageSummary {
1023 data_source: fallow_output::RuntimeCoverageDataSource::Local,
1024 last_received_at: None,
1025 functions_tracked: tracked,
1026 functions_hit: hit,
1027 functions_unhit: unhit,
1028 functions_untracked: untracked,
1029 coverage_percent,
1030 trace_count: 512,
1031 period_days: 7,
1032 deployments_seen: 2,
1033 capture_quality: None,
1034 }
1035 }
1036
1037 fn fx_evidence(
1038 static_status: &str,
1039 test_coverage: &str,
1040 v8_tracking: &str,
1041 ) -> fallow_output::RuntimeCoverageEvidence {
1042 fallow_output::RuntimeCoverageEvidence {
1043 static_status: static_status.to_owned(),
1044 test_coverage: test_coverage.to_owned(),
1045 test_only_reference: None,
1046 v8_tracking: v8_tracking.to_owned(),
1047 untracked_reason: None,
1048 observation_days: 7,
1049 deployments_observed: 2,
1050 }
1051 }
1052
1053 fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
1054 fallow_output::HealthScore {
1055 formula_version: 2,
1056 score,
1057 grade,
1058 penalties: fallow_output::HealthScorePenalties {
1059 dead_files: None,
1060 dead_exports: None,
1061 complexity: 0.0,
1062 p90_complexity: 0.0,
1063 maintainability: None,
1064 hotspots: None,
1065 unused_deps: None,
1066 circular_deps: None,
1067 unit_size: None,
1068 coupling: None,
1069 duplication: None,
1070 prop_drilling: None,
1071 },
1072 }
1073 }
1074
1075 fn fx_gate_result(
1076 findings: Vec<fallow_output::HealthFinding>,
1077 score: Option<fallow_output::HealthScore>,
1078 ) -> HealthResult {
1079 HealthResult {
1080 branching_by_file: fallow_engine::health::BranchingByFile::default(),
1081 report: fallow_output::HealthReport {
1082 findings,
1083 health_score: score,
1084 ..fallow_output::HealthReport::default()
1085 },
1086 grouping: None,
1087 group_resolver: None,
1088 config: test_resolved_config(),
1089 workspace_diagnostics: Vec::new(),
1090 elapsed: Duration::default(),
1091 timings: None,
1092 type_aware_meta: None,
1093 coverage_gaps_has_findings: false,
1094 should_fail_on_coverage_gaps: false,
1095 changed_files_analyzed: None,
1096 }
1097 }
1098
1099 fn moderate_finding() -> fallow_output::HealthFinding {
1100 make_finding("moderate", ExceededThreshold::Cyclomatic).into()
1101 }
1102
1103 fn critical_finding() -> fallow_output::HealthFinding {
1104 let mut v = make_finding("critical", ExceededThreshold::All);
1105 v.severity = FindingSeverity::Critical;
1106 v.into()
1107 }
1108
1109 fn gate_exit(
1111 result: &HealthResult,
1112 min_score: Option<f64>,
1113 min_severity: Option<FindingSeverity>,
1114 report_only: bool,
1115 ) -> ExitCode {
1116 print_health_result(
1117 result,
1118 HealthPrintOptions {
1119 quiet: true,
1120 explain: false,
1121 gates: HealthGateOptions {
1122 min_score,
1123 min_severity,
1124 report_only,
1125 fail_on_stale_baseline: false,
1126 fail_on_parse_error: false,
1127 fail_on_issues: false,
1128 },
1129 baseline_path: None,
1130 summary: false,
1131 summary_heading: true,
1132 show_explain_tip: true,
1133 type_aware_scope: None,
1134 skip_score_and_trend: false,
1135 css_requested: false,
1136 json_style: crate::json_style::JsonStyle::Compact,
1137 },
1138 )
1139 }
1140
1141 #[test]
1142 fn plain_health_with_findings_fails() {
1143 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1144 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
1145 }
1146
1147 #[test]
1148 fn plain_health_with_no_findings_succeeds() {
1149 let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1150 assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
1151 }
1152
1153 #[test]
1154 fn min_score_zero_never_fails_even_with_findings() {
1155 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1156 assert_eq!(
1157 gate_exit(&result, Some(0.0), None, false),
1158 ExitCode::SUCCESS
1159 );
1160 }
1161
1162 #[test]
1163 fn min_score_passing_demotes_findings_to_informational() {
1164 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1165 assert_eq!(
1166 gate_exit(&result, Some(80.0), None, false),
1167 ExitCode::SUCCESS
1168 );
1169 }
1170
1171 #[test]
1172 fn min_score_below_threshold_fails() {
1173 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1174 assert_eq!(
1175 gate_exit(&result, Some(80.0), None, false),
1176 ExitCode::from(1)
1177 );
1178 }
1179
1180 #[test]
1181 fn min_severity_gates_on_severity_independent_of_min_score() {
1182 let only_moderate =
1183 fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1184 assert_eq!(
1185 gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
1186 ExitCode::SUCCESS,
1187 );
1188 let with_critical = fx_gate_result(
1189 vec![moderate_finding(), critical_finding()],
1190 Some(fx_health_score(87.5, "A")),
1191 );
1192 assert_eq!(
1193 gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
1194 ExitCode::from(1),
1195 );
1196 }
1197
1198 #[test]
1199 fn min_score_and_min_severity_compose_as_or() {
1200 let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
1201 assert_eq!(
1202 gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
1203 ExitCode::SUCCESS,
1204 );
1205 let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
1206 assert_eq!(
1207 gate_exit(
1208 &low_score,
1209 Some(80.0),
1210 Some(FindingSeverity::Critical),
1211 false
1212 ),
1213 ExitCode::from(1),
1214 );
1215 let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
1216 assert_eq!(
1217 gate_exit(
1218 &critical,
1219 Some(80.0),
1220 Some(FindingSeverity::Critical),
1221 false
1222 ),
1223 ExitCode::from(1),
1224 );
1225 }
1226
1227 #[test]
1228 fn report_only_never_fails_on_findings_or_low_score() {
1229 let result = fx_gate_result(
1230 vec![moderate_finding(), critical_finding()],
1231 Some(fx_health_score(10.0, "F")),
1232 );
1233 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1234 }
1235
1236 #[test]
1237 fn runtime_coverage_gate_independent_of_min_score() {
1238 let result = fx_low_traffic_runtime_result();
1239 assert_eq!(
1240 gate_exit(&result, Some(0.0), None, false),
1241 ExitCode::from(1)
1242 );
1243 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
1244 }
1245
1246 fn fx_low_traffic_runtime_result() -> HealthResult {
1247 HealthResult {
1248 branching_by_file: fallow_engine::health::BranchingByFile::default(),
1249 report: fallow_output::HealthReport {
1250 runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
1251 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
1252 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
1253 signals: Vec::new(),
1254 summary: fx_summary(1, 0, 1, 0),
1255 findings: vec![fallow_output::RuntimeCoverageFinding {
1256 id: "fallow:prod:lowtraffic".to_owned(),
1257 stable_id: None,
1258 path: PathBuf::from("/project/src/cold.ts"),
1259 function: "coldPath".to_owned(),
1260 line: 14,
1261 verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
1262 invocations: Some(1),
1263 confidence: fallow_output::RuntimeCoverageConfidence::Low,
1264 evidence: fx_evidence("used", "not_covered", "tracked"),
1265 actions: vec![],
1266 source_hash: None,
1267 discriminators: None,
1268 }],
1269 hot_paths: vec![],
1270 blast_radius: vec![],
1271 importance: vec![],
1272 watermark: None,
1273 warnings: vec![],
1274 actionable: true,
1275 actionability_reason: None,
1276 actionability_verdict: None,
1277 provenance: fallow_output::RuntimeCoverageProvenance::default(),
1278 }),
1279 ..fallow_output::HealthReport::default()
1280 },
1281 grouping: None,
1282 group_resolver: None,
1283 config: test_resolved_config(),
1284 workspace_diagnostics: Vec::new(),
1285 elapsed: Duration::default(),
1286 timings: None,
1287 type_aware_meta: None,
1288 coverage_gaps_has_findings: false,
1289 should_fail_on_coverage_gaps: false,
1290 changed_files_analyzed: None,
1291 }
1292 }
1293
1294 #[test]
1295 fn print_health_result_fails_on_low_traffic_runtime_coverage() {
1296 let result = fx_low_traffic_runtime_result();
1297
1298 assert_eq!(
1299 print_health_result(
1300 &result,
1301 HealthPrintOptions {
1302 quiet: true,
1303 explain: false,
1304 gates: HealthGateOptions::default(),
1305 baseline_path: None,
1306 summary: false,
1307 summary_heading: true,
1308 show_explain_tip: true,
1309 type_aware_scope: None,
1310 skip_score_and_trend: false,
1311 css_requested: false,
1312 json_style: crate::json_style::JsonStyle::Compact,
1313 },
1314 ),
1315 ExitCode::from(1),
1316 );
1317 }
1318
1319 fn incomplete_required_type_aware_meta() -> fallow_types::envelope::TypeAwareMeta {
1322 fallow_types::envelope::TypeAwareMeta {
1323 required_completeness: Some(
1324 fallow_types::semantic::SemanticCompletenessRequirement::Complete,
1325 ),
1326 queries: vec![fallow_types::semantic::SemanticQuerySummary {
1327 query_id: 0,
1328 capability: fallow_types::semantic::SemanticCapability::TypeCoupling,
1329 assertion: "type coupling".to_string(),
1330 status: fallow_types::semantic::SemanticCompleteness::Partial,
1331 reason_code: None,
1332 total_evidence_count: 0,
1333 truncated: false,
1334 omissions: Vec::new(),
1335 actions: Vec::new(),
1336 }],
1337 ..Default::default()
1338 }
1339 }
1340
1341 #[test]
1342 fn the_envelope_states_a_failed_type_aware_completeness_gate() {
1343 let mut result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
1344 result.type_aware_meta = Some(incomplete_required_type_aware_meta());
1345 let options = HealthPrintOptions {
1346 quiet: true,
1347 explain: false,
1348 gates: HealthGateOptions::default(),
1349 baseline_path: None,
1350 summary: false,
1351 summary_heading: true,
1352 show_explain_tip: true,
1353 type_aware_scope: None,
1354 skip_score_and_trend: false,
1355 css_requested: false,
1356 json_style: crate::json_style::JsonStyle::Compact,
1357 };
1358 let gates = serde_json::to_value(health_gate_outcomes(&result, options))
1359 .expect("gate outcomes serialize");
1360 assert_eq!(
1361 gates["type-aware-require"],
1362 serde_json::json!({ "status": "fail", "enforced": true }),
1363 "the run exits 1 on this gate, so the envelope must state it: {gates}"
1364 );
1365 }
1366}