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