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