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