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