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