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