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