Skip to main content

fallow_engine/health/
mod.rs

1//! Command-neutral health execution options and runners.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::{EmailMode, WorkspaceInfo};
6use fallow_output::{
7    DiffIndex, EffortEstimate, FindingSeverity, GroupByMode, RuntimeCoverageReport,
8    RuntimeCoverageWatermark,
9};
10use fallow_types::output_format::OutputFormat;
11use fallow_types::path_util::is_absolute_path_any_platform;
12use fallow_types::results::AnalysisResults;
13use rustc_hash::{FxHashMap, FxHashSet};
14
15use crate::module_graph::RetainedModuleGraph;
16use crate::results::DeadCodeAnalysisArtifacts;
17
18mod actions;
19mod analysis_data;
20mod assembly;
21mod baseline_io;
22mod branching;
23pub use branching::{BranchingByFile, branching_by_file};
24mod churn_file;
25mod component_rollup;
26mod core_pipeline;
27mod coverage_gaps;
28mod coverage_intelligence;
29mod coverage_settings;
30mod coverage_v8;
31mod css_analytics;
32mod derived_sections;
33pub(crate) mod diagnostics;
34mod execute;
35mod file_scores;
36mod filters;
37mod finding_sort;
38mod findings;
39mod findings_pipeline;
40mod framework_health;
41mod grouping;
42pub use grouping::validate_group_filter;
43mod health_error;
44mod hotspots;
45mod ignore;
46mod inline;
47pub use inline::{InlineComplexity, inline_complexity};
48mod large_functions;
49mod output_build;
50pub mod ownership;
51mod package_json;
52mod pipeline;
53mod react_hooks;
54mod result;
55mod runner;
56mod runtime_filter;
57mod runtime_sections;
58mod scope;
59/// File health scoring: maintainability index, CRAP risk, triage concern
60/// classification, and Istanbul coverage ingestion.
61pub mod scoring;
62pub mod styling_score;
63mod tailwind_theme;
64mod targets;
65mod threshold_overrides;
66mod timings;
67mod vital_data;
68mod vital_signs_scope;
69
70pub use crate::results::HealthAnalysisResult;
71pub use churn_file::validate_health_churn_file;
72pub use css_analytics::StylingAnalysisArtifacts;
73use derived_sections::{
74    HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
75};
76use execute::HealthOptions;
77pub use execute::execute_health_inner;
78use file_scores::{
79    FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
80    print_slow_churn_note,
81};
82use finding_sort::sort_findings;
83pub use health_error::HealthError;
84pub use hotspots::{
85    TargetChurnEvidence, TargetChurnOptions, TargetChurnOutcome, analyze_target_churn,
86};
87pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
88pub use runner::{
89    UngroupedHealthSession, run_ungrouped_health_with_session,
90    run_ungrouped_health_with_session_artifacts,
91};
92use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
93use vital_signs_scope::{
94    SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
95    compute_vital_signs_and_counts,
96};
97
98pub(crate) fn build_styling_analysis_artifacts(
99    files: &[crate::discover::DiscoveredFile],
100    modules: &[crate::source::ModuleInfo],
101    config: &fallow_config::ResolvedConfig,
102) -> StylingAnalysisArtifacts {
103    css_analytics::build_styling_analysis_artifacts(files, modules, config)
104}
105
106/// Build health shared parse data from retained dead-code artifacts.
107#[must_use]
108pub fn shared_parse_data_from_artifacts(
109    results: &AnalysisResults,
110    graph: Option<RetainedModuleGraph>,
111    modules: Option<Vec<crate::source::ModuleInfo>>,
112    files: Option<Vec<crate::discover::DiscoveredFile>>,
113    workspaces: Vec<WorkspaceInfo>,
114    script_used_packages: impl IntoIterator<Item = String>,
115) -> Option<HealthSharedParseData> {
116    let (Some(modules), Some(files)) = (modules, files) else {
117        return None;
118    };
119    let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
120    // The vital signs and file scores measure the code, not the report, so
121    // they count the findings that the cascade filter hid. Standalone
122    // `health` runs no rule pass and sees the same set.
123    let mut results = results.clone();
124    results.restore_cascade_findings();
125    let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
126        results: results.clone(),
127        timings: None,
128        graph: Some(graph),
129        modules: None,
130        files: None,
131        script_used_packages: script_used_packages.clone(),
132        trace_provenance: crate::trace::TraceProvenance::default(),
133        file_hashes: FxHashMap::default(),
134    });
135    Some(HealthSharedParseData {
136        files,
137        modules,
138        dead_code_results: Some(results),
139        workspaces,
140        analysis_output,
141    })
142}
143
144/// Return true when health sections will need dead-code analysis artifacts.
145///
146/// Callers that already have a session and parsed modules can precompute these
147/// artifacts once, then pass them into [`HealthPipelineInputs`] to avoid a
148/// second graph and dead-code analysis inside the health pipeline.
149#[must_use]
150pub fn should_precompute_dead_code_analysis(
151    options: &HealthExecutionOptions<'_>,
152    config: &fallow_config::ResolvedConfig,
153) -> bool {
154    let max_crap = options
155        .thresholds
156        .max_crap
157        .unwrap_or(config.health.max_crap);
158    options.file_scores
159        || options.coverage_gaps
160        || options.config_activates_coverage_gaps
161        || options.hotspots
162        || options.targets
163        || options.force_full
164        || max_crap > 0.0
165        || options.runtime_coverage.is_some()
166}
167
168/// Command-neutral grouping resolver contract for `--group-by` health output.
169///
170/// The CLI owns the concrete resolver (CODEOWNERS parsing, package discovery);
171/// the engine grouping pass only needs these three read operations, so it stays
172/// generic over the resolver instead of depending on the CLI type.
173pub trait HealthGroupResolver {
174    /// Stable label for the active grouping mode (`owner` / `directory` / ...).
175    fn mode_label(&self) -> &'static str;
176    /// Resolve a repo-relative path to its group key and the matching rule.
177    fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
178    /// Section owners for the group a path belongs to, when known.
179    fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
180}
181
182/// Placeholder grouping resolver for runs without `--group-by` (the programmatic
183/// API path). Constructed only as `None`, so its methods are never invoked.
184#[derive(Debug, Clone, Copy)]
185pub enum NoGroupResolver {}
186
187#[expect(
188    clippy::uninhabited_references,
189    reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
190)]
191impl HealthGroupResolver for NoGroupResolver {
192    fn mode_label(&self) -> &'static str {
193        match *self {}
194    }
195    fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
196        match *self {}
197    }
198    fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
199        match *self {}
200    }
201}
202
203/// Runtime coverage analysis seam.
204///
205/// Runtime coverage execution drives the closed-source `fallow-cov` sidecar
206/// (license verification, subprocess spawning), which stays in the CLI. The
207/// engine calls this callback only when [`HealthExecutionOptions::runtime_coverage`]
208/// is set, so the default and programmatic paths never touch it.
209///
210/// The seam prints its own errors (license / sidecar diagnostics), so it returns
211/// the already-printed exit code as a bare `u8`. The engine wraps that code in
212/// [`HealthError::Printed`] so the CLI boundary honors the code without emitting
213/// a second error document.
214pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
215    + 'a;
216
217/// Inputs the runtime coverage seam needs from the analysis core.
218pub struct RuntimeCoverageSeamInput<'a> {
219    /// Project root the analysis ran against.
220    pub root: &'a Path,
221    /// Parsed modules from the extract phase, for correlating trace symbols.
222    pub modules: &'a [fallow_types::extract::ModuleInfo],
223    /// Retained dead-code artifacts (graph plus results) the sidecar joins
224    /// runtime traces against.
225    pub analysis_output: &'a DeadCodeAnalysisArtifacts,
226    /// Parsed Istanbul test coverage when the run also supplied it.
227    pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
228    /// `FileId` to absolute-path lookup for resolving finding locations.
229    pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
230    /// Compiled ignore globs; matching files are excluded from verdicts.
231    pub ignore_set: &'a globset::GlobSet,
232    /// Diff scope when the run is limited to changed files.
233    pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
234    /// Workspace roots when the run is workspace-scoped.
235    pub ws_roots: Option<&'a [PathBuf]>,
236    /// Cap on rendered findings, forwarded from `--top`.
237    pub top: Option<usize>,
238    /// CODEOWNERS override path for ownership attribution on findings.
239    pub codeowners_path: Option<&'a str>,
240    /// Suppress progress notes on stderr.
241    pub quiet: bool,
242    /// Output format the seam should render its own diagnostics in.
243    pub output: OutputFormat,
244}
245
246/// CLI-supplied callbacks the command-neutral health pipeline needs.
247///
248/// The pipeline itself stays cli-free; these are the seams the CLI threads in.
249pub struct HealthSeams<'a> {
250    /// Runs the runtime coverage sidecar (only when runtime coverage is set).
251    pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
252    /// Records module-graph structure facts (graph node count, edge count) into
253    /// the CLI's process-global telemetry sinks. Best-effort; the engine never
254    /// owns telemetry state.
255    pub note_graph_structure: &'a dyn Fn(usize, usize),
256}
257
258/// Command-neutral sort criteria for health complexity findings.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum HealthSort {
261    /// Worst first: exceeded-threshold class, then severity, CRAP presence,
262    /// and raw complexity metrics as tie-breakers.
263    Severity,
264    /// Descending cyclomatic complexity.
265    Cyclomatic,
266    /// Descending cognitive complexity.
267    Cognitive,
268    /// Descending function line count.
269    Lines,
270}
271
272/// Command-neutral threshold overrides for health complexity findings.
273#[derive(Debug, Clone, Copy, Default, PartialEq)]
274pub struct HealthThresholdOverrides {
275    /// Overrides the configured maximum cyclomatic complexity threshold.
276    pub max_cyclomatic: Option<u16>,
277    /// Overrides the configured maximum cognitive complexity threshold.
278    pub max_cognitive: Option<u16>,
279    /// Maximum CRAP score threshold. Functions meeting or exceeding this score
280    /// are reported as complexity findings.
281    pub max_crap: Option<f64>,
282}
283
284/// Command-neutral Istanbul coverage inputs for health CRAP scoring.
285#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
286pub struct HealthCoverageInputs<'a> {
287    /// Path to an Istanbul `coverage-final.json` used for CRAP scoring.
288    pub coverage: Option<&'a Path>,
289    /// Absolute coverage-path prefix to strip before rebasing files onto the
290    /// project root.
291    pub coverage_root: Option<&'a Path>,
292    /// The coverage map was recorded against a different checkout of this
293    /// project (the audit base-worktree pass), so function line numbers may
294    /// have drifted arbitrarily. Enables the distance-free unambiguous-name
295    /// match in the Istanbul lookup; keep `false` for same-checkout coverage,
296    /// where the bounded fuzz protects against stale data (#2347).
297    pub coverage_relocated: bool,
298}
299
300/// Validate that a coverage-data root is absolute under Unix or Windows path
301/// conventions.
302///
303/// Istanbul coverage paths often come from a Linux CI runner even when fallow
304/// is invoked on another host, so POSIX-rooted paths and Windows drive paths
305/// are both accepted on every platform.
306pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
307    if let Some(path) = coverage_root
308        && !is_absolute_path_any_platform(path)
309    {
310        return Err(format!(
311            "--coverage-root expects an absolute path prefix from the coverage data, got '{}'. Use the checkout prefix from the machine that generated coverage, for example '/home/runner/work/myapp'.",
312            path.display()
313        ));
314    }
315    Ok(())
316}
317
318/// Command-neutral health exit gate options.
319#[derive(Debug, Clone, Copy, Default, PartialEq)]
320pub struct HealthGateOptions {
321    /// Fail the run when the health score (0-100) falls below this value.
322    pub min_score: Option<f64>,
323    /// Fail the run when any finding at or above this severity exists.
324    pub min_severity: Option<FindingSeverity>,
325    /// Render the score and findings but never fail CI on a health gate.
326    pub report_only: bool,
327    /// Fail the run when a loaded `--baseline` has entries that match nothing.
328    pub fail_on_stale_baseline: bool,
329    /// Fail the run when a source file did not parse cleanly (the
330    /// `parse-error` gate). Set from `--fail-on-parse-error`; the CLI adds the
331    /// `failOnParseError` config key before the gate is evaluated.
332    pub fail_on_parse_error: bool,
333    /// Raise every `warn` complexity finding to `error`, for
334    /// `--fail-on-issues`. Dead-code `warn` findings are raised the same way,
335    /// so every finding of such a run fails it.
336    pub fail_on_issues: bool,
337}
338
339/// Input for deriving effective health sections from command-neutral flags.
340#[derive(Debug, Clone)]
341pub struct HealthSectionOptions {
342    output: OutputFormat,
343    complexity: bool,
344    file_scores: bool,
345    coverage_gaps: bool,
346    hotspots: bool,
347    targets: bool,
348    css: bool,
349    score: bool,
350    score_gate: bool,
351    snapshot_requested: bool,
352    trend: bool,
353}
354
355/// Derived section selection for health runs.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub struct DerivedHealthSections {
358    /// True when at least one section was explicitly requested; a request with
359    /// no explicit sections defaults to the full section set.
360    pub any_section: bool,
361    /// Render the complexity findings section.
362    pub complexity: bool,
363    /// Render the per-file health scores section.
364    pub file_scores: bool,
365    /// Render the static coverage gaps section.
366    pub coverage_gaps: bool,
367    /// Render the churn-based hotspots section.
368    pub hotspots: bool,
369    /// Render the refactoring targets section.
370    pub targets: bool,
371    /// Render the CSS / styling analytics section.
372    pub css: bool,
373    /// Compute and render the overall health score.
374    pub score: bool,
375    /// Analyze the full project even when only a subset of sections was
376    /// requested, because scores and snapshots need complete data.
377    pub force_full: bool,
378    /// True when the score is the only requested surface, so output can skip
379    /// section rendering entirely.
380    pub score_only_output: bool,
381}
382
383/// Command-neutral inputs used to normalize a health run before it reaches a
384/// concrete runner.
385#[derive(Debug, Clone)]
386pub struct HealthRunOptionsInput<'a> {
387    /// Output format; badge output implies the score section.
388    pub output: OutputFormat,
389    /// Complexity threshold overrides on top of the resolved config.
390    pub thresholds: HealthThresholdOverrides,
391    /// Cap on rendered findings per section.
392    pub top: Option<usize>,
393    /// Sort criteria for complexity findings.
394    pub sort: HealthSort,
395    /// Explicit request for the complexity findings section.
396    pub complexity: bool,
397    /// Explicit request for the per-file health scores section.
398    pub file_scores: bool,
399    /// Explicit request for the static coverage gaps section.
400    pub coverage_gaps: bool,
401    /// Explicit request for the churn-based hotspots section.
402    pub hotspots: bool,
403    /// Attribute hotspots to owners (implies the hotspots section data).
404    pub ownership: bool,
405    /// How owner identities are rendered (names or emails).
406    pub ownership_emails: Option<EmailMode>,
407    /// Explicit request for the refactoring targets section.
408    pub targets: bool,
409    /// Explicit request for the CSS / styling analytics section.
410    pub css: bool,
411    /// Effort estimation mode; requesting it also enables the targets section.
412    pub effort: Option<EffortEstimate>,
413    /// Explicit request for the overall health score.
414    pub score: bool,
415    /// Exit gate thresholds; a score gate implies the score section.
416    pub gates: HealthGateOptions,
417    /// True when the run should persist a health snapshot (forces a full run).
418    pub snapshot_requested: bool,
419    /// Explicit request for the score trend section (implies score).
420    pub trend: bool,
421    /// Churn lookback window for hotspots (`90d`, `6m`, `1y`, or an ISO date).
422    pub since: Option<&'a str>,
423    /// Minimum commit count for a file to qualify as churn evidence.
424    pub min_commits: Option<u32>,
425    /// Istanbul coverage inputs for CRAP scoring.
426    pub coverage_inputs: HealthCoverageInputs<'a>,
427    /// Runtime coverage sidecar options, when runtime analysis was requested.
428    pub runtime_coverage: Option<RuntimeCoverageOptions>,
429}
430
431/// Normalized health inputs shared by CLI, API, NAPI, and future runners.
432#[derive(Debug, Clone)]
433pub struct HealthRunOptions<'a> {
434    /// Complexity threshold overrides on top of the resolved config.
435    pub thresholds: HealthThresholdOverrides,
436    /// Cap on rendered findings per section.
437    pub top: Option<usize>,
438    /// Sort criteria for complexity findings.
439    pub sort: HealthSort,
440    /// Effective section selection derived from the raw request flags.
441    pub sections: DerivedHealthSections,
442    /// Attribute hotspots to owners; already gated on the hotspots section.
443    pub ownership: bool,
444    /// How owner identities are rendered (names or emails).
445    pub ownership_emails: Option<EmailMode>,
446    /// Effort estimation mode for refactoring targets.
447    pub effort: Option<EffortEstimate>,
448    /// Exit gate thresholds.
449    pub gates: HealthGateOptions,
450    /// Churn lookback window for hotspots (`90d`, `6m`, `1y`, or an ISO date).
451    pub since: Option<&'a str>,
452    /// Minimum commit count for a file to qualify as churn evidence.
453    pub min_commits: Option<u32>,
454    /// Istanbul coverage inputs for CRAP scoring.
455    pub coverage_inputs: HealthCoverageInputs<'a>,
456    /// Runtime coverage sidecar options, when runtime analysis was requested.
457    pub runtime_coverage: Option<RuntimeCoverageOptions>,
458}
459
460/// Command-neutral inputs needed to execute a health analysis.
461///
462/// These fields are shared runner inputs rather than rendering concerns.
463#[derive(Debug, Clone)]
464pub struct HealthExecutionOptions<'a> {
465    /// Project root to analyze.
466    pub root: &'a Path,
467    /// Explicit config file path; `None` triggers automatic discovery.
468    pub config_path: &'a Option<PathBuf>,
469    /// Output format of the run; badge output implies the score section.
470    pub output: OutputFormat,
471    /// Bypass the parse cache for this run.
472    pub no_cache: bool,
473    /// Worker thread count for parsing and analysis.
474    pub threads: usize,
475    /// Suppress progress notes on stderr.
476    pub quiet: bool,
477    /// Include per-decision-point complexity contributions in typed findings.
478    ///
479    /// This changes the produced health result shape, so it belongs to the
480    /// runner input contract rather than CLI rendering options.
481    pub complexity_breakdown: bool,
482    /// Complexity threshold overrides on top of the resolved config.
483    pub thresholds: HealthThresholdOverrides,
484    /// Cap on rendered findings per section.
485    pub top: Option<usize>,
486    /// Sort criteria for complexity findings.
487    pub sort: HealthSort,
488    /// Raw production-only request flag; folded into `production_override`
489    /// when the tri-state override is unset.
490    pub production: bool,
491    /// Tri-state production override: `Some` forces production-only analysis
492    /// on or off regardless of config, `None` defers to the config value.
493    pub production_override: Option<bool>,
494    /// Permit `extends` config inheritance from remote URLs.
495    pub allow_remote_extends: bool,
496    /// Git ref limiting findings to files changed since it.
497    pub changed_since: Option<&'a str>,
498    /// Pre-built diff index scoping findings to changed lines.
499    pub diff_index: Option<&'a DiffIndex>,
500    /// True when `diff_index` came from the process-shared diff source rather
501    /// than a health-specific one.
502    pub use_shared_diff_index: bool,
503    /// Workspace member paths limiting the analysis scope.
504    pub workspace: Option<&'a [String]>,
505    /// Git ref selecting only workspaces with changes since it.
506    pub changed_workspaces: Option<&'a str>,
507    /// Positional `[PATH]` scope: root-joined absolute file or directory inside
508    /// the root. Consumed CLI-side as one more workspace root (prefix scope),
509    /// so it composes with `workspace` the way multiple workspace roots
510    /// compose. `None` means whole-project scope.
511    pub scope: Option<PathBuf>,
512    /// Baseline file to compare finding counts against.
513    pub baseline: Option<&'a Path>,
514    /// Path to write the run's finding counts as a new baseline.
515    pub save_baseline: Option<&'a Path>,
516    /// Controls both halves of the baseline lifecycle: which buckets
517    /// `save_baseline` writes and how a loaded `baseline` is matched. An
518    /// identity save writes count and identity buckets, a count save writes
519    /// count buckets only.
520    pub baseline_mode: crate::baseline::HealthBaselineMode,
521    /// Whether `baseline_mode` was requested explicitly rather than defaulted.
522    /// A defaulted count save refuses to overwrite a baseline that carries
523    /// identity buckets, because dropping them breaks later identity-mode
524    /// comparisons; an explicit count request is treated as intent to
525    /// downgrade.
526    pub baseline_mode_explicit: bool,
527    /// Render the complexity findings section.
528    pub complexity: bool,
529    /// Render the per-file health scores section.
530    pub file_scores: bool,
531    /// Render the static coverage gaps section.
532    pub coverage_gaps: bool,
533    /// Let config-enabled coverage settings activate the coverage gaps
534    /// section even when it was not requested on this run.
535    pub config_activates_coverage_gaps: bool,
536    /// Render the churn-based hotspots section.
537    pub hotspots: bool,
538    /// Attribute hotspots to owners.
539    pub ownership: bool,
540    /// How owner identities are rendered (names or emails).
541    pub ownership_emails: Option<EmailMode>,
542    /// Render the refactoring targets section.
543    pub targets: bool,
544    /// Render the CSS / styling analytics section.
545    pub css: bool,
546    /// Scan all stylesheets for the CSS section instead of only changed files.
547    pub css_deep: bool,
548    /// Analyze the full project even when only a subset of sections was
549    /// requested, because scores and snapshots need complete data.
550    pub force_full: bool,
551    /// True when the score is the only requested surface, so output can skip
552    /// section rendering entirely.
553    pub score_only_output: bool,
554    /// Fail the run on coverage gaps instead of reporting them advisorily.
555    pub enforce_coverage_gap_gate: bool,
556    /// Effort estimation mode for refactoring targets.
557    pub effort: Option<EffortEstimate>,
558    /// Compute and render the overall health score.
559    pub score: bool,
560    /// Exit gate thresholds.
561    pub gates: HealthGateOptions,
562    /// Churn lookback window for hotspots (`90d`, `6m`, `1y`, or an ISO date).
563    pub since: Option<&'a str>,
564    /// Minimum commit count for a file to qualify as churn evidence.
565    pub min_commits: Option<u32>,
566    /// Include score-derivation explanations in the rendered output.
567    pub explain: bool,
568    /// Render the condensed summary view instead of full sections.
569    pub summary: bool,
570    /// Path to persist a health snapshot for later trend comparison.
571    pub save_snapshot: Option<PathBuf>,
572    /// Render the score trend against previously saved snapshots.
573    pub trend: bool,
574    /// Istanbul coverage inputs for CRAP scoring.
575    pub coverage_inputs: HealthCoverageInputs<'a>,
576    /// Print per-phase timing diagnostics.
577    pub performance: bool,
578    /// Runtime coverage sidecar options, when runtime analysis was requested.
579    pub runtime_coverage: Option<RuntimeCoverageOptions>,
580    /// Pre-recorded churn data file replacing live `git log` analysis.
581    pub churn_file: Option<&'a Path>,
582    /// Compatibility identity persisted with snapshots and checked by trends.
583    pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
584    /// Optional grouping mode for typed health output.
585    pub group_by: Option<GroupByMode>,
586    /// `--group` selector: keep only the groups whose key matches. Exact keys,
587    /// globs and `!`-prefixed negations. Ignored without `group_by`.
588    pub group_filter: Option<&'a [String]>,
589    /// `--trend-from`: compare against this snapshot file instead of the
590    /// newest file in `.fallow/snapshots/`. Implies `trend`.
591    pub trend_from: Option<&'a Path>,
592}
593
594/// Derive effective health section flags for CLI and embedders.
595#[must_use]
596fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
597    let score = options.score
598        || options.score_gate
599        || options.trend
600        || matches!(options.output, OutputFormat::Badge);
601    let any_section = options.complexity
602        || options.file_scores
603        || options.coverage_gaps
604        || options.hotspots
605        || options.targets
606        || score;
607    let effective_score = if any_section { score } else { true } || options.snapshot_requested;
608    let force_full = options.snapshot_requested || effective_score;
609
610    DerivedHealthSections {
611        any_section,
612        complexity: if any_section {
613            options.complexity
614        } else {
615            true
616        },
617        file_scores: if any_section {
618            options.file_scores
619        } else {
620            true
621        } || force_full,
622        coverage_gaps: if any_section {
623            options.coverage_gaps
624        } else {
625            false
626        },
627        hotspots: if any_section { options.hotspots } else { true }
628            || options.snapshot_requested
629            || options.trend,
630        targets: if any_section { options.targets } else { true },
631        css: options.css,
632        score: effective_score,
633        force_full,
634        score_only_output: is_health_score_only_output(options, score),
635    }
636}
637
638/// Normalize health run inputs into the engine-owned run contract.
639#[must_use]
640pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
641    let targets = input.targets || input.effort.is_some();
642    let sections = derive_health_sections(&HealthSectionOptions {
643        output: input.output,
644        complexity: input.complexity,
645        file_scores: input.file_scores,
646        coverage_gaps: input.coverage_gaps,
647        hotspots: input.hotspots,
648        targets,
649        css: input.css,
650        score: input.score,
651        score_gate: input.gates.min_score.is_some(),
652        snapshot_requested: input.snapshot_requested,
653        trend: input.trend,
654    });
655
656    HealthRunOptions {
657        thresholds: input.thresholds,
658        top: input.top,
659        sort: input.sort,
660        sections,
661        ownership: input.ownership && sections.hotspots,
662        ownership_emails: input.ownership_emails,
663        effort: input.effort,
664        gates: input.gates,
665        since: input.since,
666        min_commits: input.min_commits,
667        coverage_inputs: input.coverage_inputs,
668        runtime_coverage: input.runtime_coverage,
669    }
670}
671
672fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
673    score
674        && !options.complexity
675        && !options.file_scores
676        && !options.coverage_gaps
677        && !options.hotspots
678        && !options.targets
679        && !options.trend
680}
681
682/// Input for deriving effective programmatic complexity sections.
683#[derive(Debug, Clone)]
684pub struct ComplexitySectionOptions {
685    complexity: bool,
686    file_scores: bool,
687    coverage_gaps: bool,
688    hotspots: bool,
689    ownership: bool,
690    targets: bool,
691    css: bool,
692    score: bool,
693}
694
695/// Derived section selection for programmatic health / complexity runs.
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub struct DerivedComplexityOptions {
698    any_section: bool,
699    complexity: bool,
700    file_scores: bool,
701    coverage_gaps: bool,
702    hotspots: bool,
703    ownership: bool,
704    targets: bool,
705    force_full: bool,
706    score_only_output: bool,
707    score: bool,
708}
709
710/// Derive effective programmatic health / complexity section flags.
711#[must_use]
712pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
713    let requested_hotspots = options.hotspots || options.ownership;
714    let sections = derive_health_sections(&HealthSectionOptions {
715        output: OutputFormat::Human,
716        complexity: options.complexity,
717        file_scores: options.file_scores,
718        coverage_gaps: options.coverage_gaps,
719        hotspots: requested_hotspots,
720        targets: options.targets,
721        css: options.css,
722        score: options.score,
723        score_gate: false,
724        snapshot_requested: false,
725        trend: false,
726    });
727
728    DerivedComplexityOptions {
729        any_section: sections.any_section,
730        complexity: sections.complexity,
731        file_scores: sections.file_scores,
732        coverage_gaps: sections.coverage_gaps,
733        hotspots: sections.hotspots,
734        ownership: options.ownership && sections.hotspots,
735        targets: sections.targets,
736        force_full: sections.force_full,
737        score_only_output: sections.score_only_output,
738        score: sections.score,
739    }
740}
741
742/// Normalized programmatic complexity / health inputs shared by API, NAPI, and
743/// engine-backed runners.
744#[derive(Debug, Clone, PartialEq)]
745pub struct ComplexityRunOptions<'a> {
746    thresholds: HealthThresholdOverrides,
747    top: Option<usize>,
748    sort: HealthSort,
749    complexity_breakdown: bool,
750    sections: DerivedComplexityOptions,
751    ownership_emails: Option<EmailMode>,
752    effort: Option<EffortEstimate>,
753    css: bool,
754    since: Option<&'a str>,
755    min_commits: Option<u32>,
756    coverage_inputs: HealthCoverageInputs<'a>,
757}
758
759/// Command-neutral runtime coverage input for health analysis.
760#[derive(Debug, Clone)]
761pub struct RuntimeCoverageOptions {
762    /// Path to the runtime coverage artifact captured by the sidecar.
763    pub path: PathBuf,
764    /// Minimum invocation count for a function to classify as hot-path.
765    pub min_invocations_hot: u64,
766    /// Minimum total trace volume before high-confidence `safe_to_delete` /
767    /// `review_required` verdicts may be emitted. Below this the sidecar caps
768    /// confidence at `medium`. `None` lets the sidecar use its spec-default
769    /// (5000).
770    pub min_observation_volume: Option<u32>,
771    /// Fraction of total trace count below which an invoked function is
772    /// classified as `low_traffic` rather than `active`. `None` lets the
773    /// sidecar use its spec-default (0.001 = 0.1%).
774    pub low_traffic_threshold: Option<f64>,
775    /// Verified license JWT forwarded to the closed-source sidecar.
776    pub license_jwt: String,
777    /// License or trial watermark to stamp on the runtime coverage output.
778    pub watermark: Option<RuntimeCoverageWatermark>,
779}
780
781/// Pre-parsed health input reused from another analysis in the same process.
782pub struct HealthSharedParseData {
783    /// Discovered files reused from the upstream analysis.
784    pub files: Vec<fallow_types::discover::DiscoveredFile>,
785    /// Parsed modules reused from the upstream analysis.
786    pub modules: Vec<fallow_types::extract::ModuleInfo>,
787    /// Dead-code results reused by advisory health surfaces that do not need the graph.
788    pub dead_code_results: Option<AnalysisResults>,
789    /// Workspace metadata discovered during config resolution.
790    pub workspaces: Vec<WorkspaceInfo>,
791    /// Full analysis output (graph + results) for file scoring.
792    pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    fn health_run_input() -> HealthRunOptionsInput<'static> {
800        HealthRunOptionsInput {
801            output: OutputFormat::Json,
802            thresholds: HealthThresholdOverrides::default(),
803            top: None,
804            sort: HealthSort::Cyclomatic,
805            complexity: false,
806            file_scores: false,
807            coverage_gaps: false,
808            hotspots: false,
809            ownership: false,
810            ownership_emails: None,
811            targets: false,
812            css: false,
813            effort: None,
814            score: false,
815            gates: HealthGateOptions::default(),
816            snapshot_requested: false,
817            trend: false,
818            since: None,
819            min_commits: None,
820            coverage_inputs: HealthCoverageInputs::default(),
821            runtime_coverage: None,
822        }
823    }
824
825    #[test]
826    fn health_execution_options_own_shared_runner_scope() {
827        let root = Path::new("/project");
828        let config_path = None;
829        let workspace = vec!["packages/app".to_string()];
830        let diff = DiffIndex::from_unified_diff(
831            "diff --git a/src/a.ts b/src/a.ts\n\
832             --- a/src/a.ts\n\
833             +++ b/src/a.ts\n\
834             @@ -0,0 +1,1 @@\n\
835             +new line\n",
836        );
837        let runtime_coverage = RuntimeCoverageOptions {
838            path: PathBuf::from("coverage/v8"),
839            min_invocations_hot: 10,
840            min_observation_volume: Some(500),
841            low_traffic_threshold: Some(0.01),
842            license_jwt: "test.jwt".to_string(),
843            watermark: None,
844        };
845
846        let options = HealthExecutionOptions {
847            root,
848            config_path: &config_path,
849            output: OutputFormat::Json,
850            no_cache: true,
851            threads: 2,
852            quiet: true,
853            complexity_breakdown: true,
854            thresholds: HealthThresholdOverrides::default(),
855            top: Some(5),
856            sort: HealthSort::Cognitive,
857            production: true,
858            production_override: Some(true),
859            allow_remote_extends: false,
860            changed_since: Some("HEAD~1"),
861            diff_index: Some(&diff),
862            use_shared_diff_index: false,
863            workspace: Some(&workspace),
864            changed_workspaces: None,
865            scope: None,
866            baseline: Some(Path::new(".fallow/health-baseline.json")),
867            save_baseline: None,
868            baseline_mode: crate::baseline::HealthBaselineMode::Count,
869            baseline_mode_explicit: false,
870            complexity: true,
871            file_scores: true,
872            coverage_gaps: false,
873            config_activates_coverage_gaps: false,
874            hotspots: true,
875            ownership: false,
876            ownership_emails: None,
877            targets: true,
878            css: false,
879            css_deep: false,
880            force_full: true,
881            score_only_output: false,
882            enforce_coverage_gap_gate: true,
883            effort: Some(EffortEstimate::Low),
884            score: true,
885            gates: HealthGateOptions {
886                min_score: Some(80.0),
887                min_severity: None,
888                report_only: false,
889                fail_on_stale_baseline: false,
890                fail_on_parse_error: false,
891                fail_on_issues: false,
892            },
893            since: Some("30d"),
894            min_commits: Some(2),
895            explain: true,
896            summary: false,
897            save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
898            trend: true,
899            coverage_inputs: HealthCoverageInputs::default(),
900            performance: true,
901            runtime_coverage: Some(runtime_coverage),
902            churn_file: Some(Path::new("churn.json")),
903            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
904            group_by: Some(GroupByMode::Directory),
905            group_filter: None,
906            trend_from: None,
907        };
908
909        assert_eq!(options.root, root);
910        assert!(
911            options
912                .diff_index
913                .is_some_and(|index| index.line_is_added("src/a.ts", 1))
914        );
915        assert_eq!(options.workspace, Some(workspace.as_slice()));
916        assert!(options.runtime_coverage.is_some());
917        assert_eq!(options.group_by, Some(GroupByMode::Directory));
918        assert_eq!(
919            options.save_snapshot.as_deref(),
920            Some(Path::new(".fallow/snapshots/health.json"))
921        );
922    }
923
924    #[test]
925    fn health_run_options_default_sections_match_health_defaults() {
926        let run = derive_health_run_options(health_run_input());
927
928        assert!(run.sections.complexity);
929        assert!(run.sections.file_scores);
930        assert!(run.sections.hotspots);
931        assert!(run.sections.targets);
932        assert!(run.sections.score);
933        assert!(!run.ownership);
934    }
935
936    #[test]
937    fn health_run_options_effort_requests_targets() {
938        let mut input = health_run_input();
939        input.effort = Some(EffortEstimate::Low);
940
941        let run = derive_health_run_options(input);
942
943        assert!(run.sections.targets);
944        assert_eq!(run.effort, Some(EffortEstimate::Low));
945    }
946
947    struct HealthExecutionOptionsFixture {
948        config_path: Option<PathBuf>,
949    }
950
951    impl HealthExecutionOptionsFixture {
952        const fn new() -> Self {
953            Self { config_path: None }
954        }
955
956        fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
957            HealthExecutionOptions {
958                root,
959                config_path: &self.config_path,
960                output: OutputFormat::Human,
961                no_cache: true,
962                threads: 1,
963                quiet: true,
964                complexity_breakdown: false,
965                thresholds: HealthThresholdOverrides::default(),
966                top: None,
967                sort: HealthSort::Cyclomatic,
968                production: false,
969                production_override: None,
970                allow_remote_extends: false,
971                changed_since: None,
972                diff_index: None,
973                use_shared_diff_index: false,
974                workspace: None,
975                changed_workspaces: None,
976                scope: None,
977                baseline: None,
978                save_baseline: None,
979                baseline_mode: crate::baseline::HealthBaselineMode::Count,
980                baseline_mode_explicit: false,
981                complexity: true,
982                file_scores: false,
983                coverage_gaps: false,
984                config_activates_coverage_gaps: false,
985                hotspots: false,
986                ownership: false,
987                ownership_emails: None,
988                targets: false,
989                css: false,
990                css_deep: false,
991                force_full: false,
992                score_only_output: false,
993                enforce_coverage_gap_gate: true,
994                effort: None,
995                score: false,
996                gates: HealthGateOptions::default(),
997                since: None,
998                min_commits: None,
999                explain: false,
1000                summary: false,
1001                save_snapshot: None,
1002                trend: false,
1003                coverage_inputs: HealthCoverageInputs::default(),
1004                performance: false,
1005                runtime_coverage: None,
1006                churn_file: None,
1007                analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
1008                group_by: None,
1009                group_filter: None,
1010                trend_from: None,
1011            }
1012        }
1013    }
1014
1015    /// The churn-file re-read is a time-of-check path: the up-front gate
1016    /// accepted the file and it changed before the analysis read it again. The
1017    /// CLI gate exits 2 on a file that is malformed at check time, so this is
1018    /// the level that can drive the branch, and without the diagnostic such a
1019    /// run reports the hotspot, churn and ownership sections as zero rather than
1020    /// as unmeasured (issue #2734).
1021    #[test]
1022    fn a_churn_file_that_fails_the_re_read_records_the_skip() {
1023        let project = tempfile::tempdir().expect("temp dir");
1024        let root = project.path();
1025        let churn_file = root.join("churn.json");
1026        std::fs::write(&churn_file, "{ not json").expect("churn file");
1027        let fixture = HealthExecutionOptionsFixture::new();
1028        let mut options = fixture.options(root);
1029        options.churn_file = Some(&churn_file);
1030
1031        assert!(
1032            hotspots::fetch_churn_data(&options, &root.join(".fallow")).is_none(),
1033            "an unreadable churn file yields no churn"
1034        );
1035
1036        let recorded = fallow_config::health_stage_workspace_diagnostics(root);
1037        let skip = recorded
1038            .iter()
1039            .find(|entry| entry.kind.id() == "hotspots-skipped")
1040            .expect("the skip is recorded");
1041        assert_eq!(
1042            skip.kind,
1043            fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
1044                cause: "churn-file-unreadable".to_owned(),
1045            }
1046        );
1047        assert!(
1048            skip.message.contains("churn.json"),
1049            "the remedy names the file: {}",
1050            skip.message
1051        );
1052    }
1053
1054    #[test]
1055    fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
1056        let project = tempfile::tempdir().expect("temp dir");
1057        let fixture = HealthExecutionOptionsFixture::new();
1058        let options = fixture.options(project.path());
1059        let config = crate::project_config::default_project_config(project.path()).config;
1060
1061        assert!(should_precompute_dead_code_analysis(&options, &config));
1062    }
1063
1064    #[test]
1065    fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
1066        let project = tempfile::tempdir().expect("temp dir");
1067        let fixture = HealthExecutionOptionsFixture::new();
1068        let mut options = fixture.options(project.path());
1069        options.thresholds.max_crap = Some(0.0);
1070        let config = crate::project_config::default_project_config(project.path()).config;
1071
1072        assert!(!should_precompute_dead_code_analysis(&options, &config));
1073    }
1074
1075    #[test]
1076    fn standalone_health_precomputes_dead_code_for_target_sections() {
1077        let project = tempfile::tempdir().expect("temp dir");
1078        let fixture = HealthExecutionOptionsFixture::new();
1079        let mut options = fixture.options(project.path());
1080        options.thresholds.max_crap = Some(0.0);
1081        options.targets = true;
1082        let config = crate::project_config::default_project_config(project.path()).config;
1083
1084        assert!(should_precompute_dead_code_analysis(&options, &config));
1085    }
1086
1087    #[test]
1088    fn health_run_options_ownership_requires_hotspots() {
1089        let mut input = health_run_input();
1090        input.complexity = true;
1091        input.ownership = true;
1092
1093        let run = derive_health_run_options(input);
1094
1095        assert!(!run.sections.hotspots);
1096        assert!(!run.ownership);
1097
1098        let mut input = health_run_input();
1099        input.ownership = true;
1100        input.hotspots = true;
1101
1102        let run = derive_health_run_options(input);
1103
1104        assert!(run.sections.hotspots);
1105        assert!(run.ownership);
1106    }
1107
1108    #[test]
1109    fn health_run_options_score_gate_forces_score() {
1110        let mut input = health_run_input();
1111        input.gates.min_score = Some(90.0);
1112
1113        let run = derive_health_run_options(input);
1114
1115        assert!(run.sections.score);
1116        assert_eq!(run.gates.min_score, Some(90.0));
1117    }
1118
1119    #[test]
1120    fn coverage_root_accepts_posix_absolute() {
1121        assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1122        assert!(
1123            validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1124        );
1125    }
1126
1127    #[test]
1128    fn coverage_root_rejects_relative() {
1129        assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1130        assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1131        assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1132    }
1133
1134    #[test]
1135    fn coverage_root_accepts_none() {
1136        assert!(validate_coverage_root_absolute(None).is_ok());
1137    }
1138
1139    #[test]
1140    fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1141        assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1142    }
1143}