Skip to main content

fallow_engine/health/
mod.rs

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