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