Skip to main content

fallow_api/runtime/
mod.rs

1//! Programmatic runtime entry points that avoid depending on `fallow-cli`.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::{FallowConfig, HealthConfig, ProductionConfig};
6use fallow_engine::{
7    dead_code::DeadCodeAnalysisArtifacts,
8    duplicates::DuplicationReport,
9    project_config::{ProductionFlags, ProductionModes},
10    session::AnalysisSession,
11};
12use fallow_output::{HealthGrouping, HealthReport};
13use fallow_types::output_format::OutputFormat;
14use fallow_types::workspace::WorkspaceDiagnostic;
15use rustc_hash::FxHashSet;
16
17mod audit;
18mod combined;
19mod dead_code;
20mod decision_surface;
21mod duplication;
22mod feature_flags;
23mod mutation;
24mod similar_code;
25mod trace;
26mod type_aware_config;
27
28pub use crate::runtime_output::{
29    ArchitectureOutput, ArchitectureProgrammaticOutput, AuditProgrammaticKeySnapshot,
30    AuditProgrammaticOutput, BoundaryViolationsOutput, BoundaryViolationsProgrammaticOutput,
31    CircularDependenciesOutput, CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput,
32    DeadCodeOutput, DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput,
33    DuplicationOutput, DuplicationProgrammaticOutput, FeatureFlagsOutput,
34    FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
35    TraceClassMemberOutput, TraceCloneOutput, TraceCloneProgrammaticOutput, TraceDependencyOutput,
36    TraceDependencyProgrammaticOutput, TraceErrorOutput, TraceErrorProgrammaticOutput,
37    TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
38    TraceFileProgrammaticOutput, TraceImportPathOutput, TraceImportPathProgrammaticOutput,
39    serialize_health_report_json,
40};
41pub use audit::{AUDIT_CONFIG_BASELINE_UNSUPPORTED, run_audit};
42pub use combined::run_combined;
43pub use dead_code::{
44    run_architecture, run_boundary_violations, run_circular_dependencies, run_dead_code,
45    run_dead_code_with_baseline,
46};
47pub use decision_surface::run_decision_surface;
48pub use duplication::run_duplication;
49pub use feature_flags::run_feature_flags;
50pub use mutation::{plan_mutation, run_mutation};
51pub use similar_code::{
52    inspect_similar_code, parse_similar_code_candidate_snapshot, review_similar_code,
53    run_similar_code, select_similar_code_candidate_snapshot,
54};
55pub use trace::{
56    TraceCloneBenchmarkResult, benchmark_trace_clone_compact_json,
57    benchmark_trace_graph_family_compact_json, run_trace_clone, run_trace_dependency,
58    run_trace_error, run_trace_export, run_trace_file, run_trace_import_path,
59};
60pub use type_aware_config::TYPE_AWARE_CONFIG_UNSUPPORTED;
61
62use crate::{
63    ComplexityOptions, ProgrammaticError,
64    analysis_context::{
65        ProgrammaticAnalysisContext, resolve_programmatic_analysis_context,
66        workspace_roots_for_session,
67    },
68    derive_complexity_options,
69    next_steps::{audit_changed_applicable, setup_pointer_applicable, suggestions_enabled},
70};
71
72type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
73
74pub(super) fn resolve_effective_production_modes(
75    resolved: &ProgrammaticAnalysisContext,
76    dead_code_override: Option<bool>,
77    health_override: Option<bool>,
78    dupes_override: Option<bool>,
79) -> ProgrammaticResult<ProductionModes> {
80    let config = load_context_production_config(resolved)?;
81    Ok(ProductionFlags {
82        global: resolved.production_override(),
83        dead_code: dead_code_override,
84        health: health_override,
85        dupes: dupes_override,
86    }
87    .effective_modes(config))
88}
89
90fn load_context_production_config(
91    resolved: &ProgrammaticAnalysisContext,
92) -> ProgrammaticResult<ProductionConfig> {
93    let loaded = load_config_file(
94        resolved.root(),
95        resolved.config_path().as_deref(),
96        resolved.allow_remote_extends(),
97    )?;
98    Ok(loaded.map_or_else(ProductionConfig::default, |config| config.production))
99}
100
101/// Load the `health` section of the project config for adapters that layer
102/// config-sourced coverage inputs before building options (the MCP typed
103/// route; see [`crate::coverage`]). The root and config path resolve exactly
104/// as they do for the analysis itself. Returns `None` when the project has no
105/// config file.
106///
107/// # Errors
108///
109/// Returns the analysis-context errors for an invalid root or config path,
110/// and `FALLOW_CONFIG_LOAD_FAILED` when the config cannot be loaded.
111pub fn load_health_config(
112    options: &crate::AnalysisOptions,
113) -> ProgrammaticResult<Option<HealthConfig>> {
114    let root = crate::analysis_context::resolve_analysis_root(options.root.as_deref())?;
115    crate::analysis_context::validate_analysis_config_path(options.config_path.as_deref())?;
116    let loaded = load_config_file(
117        &root,
118        options.config_path.as_deref(),
119        options.allow_remote_extends,
120    )?;
121    Ok(loaded.map(|config| config.health))
122}
123
124fn load_config_file(
125    root: &Path,
126    config_path: Option<&Path>,
127    allow_remote_extends: bool,
128) -> ProgrammaticResult<Option<FallowConfig>> {
129    let load_options = fallow_config::ConfigLoadOptions {
130        allow_remote_extends,
131    };
132    if let Some(path) = config_path {
133        return FallowConfig::load_with_options(path, load_options)
134            .map(Some)
135            .map_err(|err| config_load_error(format!("failed to load config: {err:#}")));
136    }
137    FallowConfig::find_and_load_with_options(root, load_options)
138        .map(|found| found.map(|(config, _)| config))
139        .map_err(|err| config_load_error(format!("failed to load config: {err}")))
140}
141
142fn config_load_error(message: String) -> ProgrammaticError {
143    ProgrammaticError::new(message, 2)
144        .with_code("FALLOW_CONFIG_LOAD_FAILED")
145        .with_context("analysis.configPath")
146}
147
148pub(super) fn health_may_consume_dead_code_artifacts(
149    options: &ComplexityOptions,
150    config: &fallow_config::ResolvedConfig,
151) -> bool {
152    let sections = derive_complexity_options(options);
153    let max_crap = options.max_crap.unwrap_or(config.health.max_crap);
154    sections.file_scores
155        || sections.coverage_gaps
156        || sections.hotspots
157        || sections.targets
158        || sections.force_full
159        || max_crap > 0.0
160}
161
162pub(super) fn health_may_consume_duplication_report(options: &ComplexityOptions) -> bool {
163    let sections = derive_complexity_options(options);
164    sections.score || sections.targets
165}
166
167/// Runtime probes used by programmatic health output assembly.
168///
169/// Concrete runners supply environment and project facts while the stable
170/// command strings and output ordering remain owned by `fallow-output`.
171pub struct ProgrammaticHealthNextStepFacts {
172    /// False when `FALLOW_SUGGESTIONS=off`; suppresses all steps.
173    pub suggestions_enabled: bool,
174    /// Offer the guided-setup pointer because no fallow config exists yet.
175    pub offer_setup: bool,
176    /// Local impact digest counters, when a digest is available.
177    pub impact_digest: Option<fallow_output::ImpactDigestCounts>,
178    /// Offer `fallow audit` because the working tree has changed files.
179    pub audit_changed: bool,
180}
181
182/// API-owned health analysis payload returned by programmatic runners.
183///
184/// The engine owns execution, but this type is the public runner contract so
185/// embedders do not have to construct or depend on engine result structs.
186pub struct ProgrammaticHealthAnalysis {
187    /// Typed health report produced by the engine.
188    pub report: HealthReport,
189    /// Grouped findings when a grouping mode was requested.
190    pub grouping: Option<HealthGrouping>,
191    /// Resolved analysis root the report paths are relative to.
192    pub root: PathBuf,
193    /// Analysis wall time.
194    pub elapsed: std::time::Duration,
195}
196
197impl ProgrammaticHealthAnalysis {
198    fn from_engine<GroupResolver>(
199        analysis: fallow_engine::health::HealthAnalysisResult<GroupResolver>,
200    ) -> Self {
201        Self {
202            root: analysis.config.root,
203            report: analysis.report,
204            grouping: analysis.grouping,
205            elapsed: analysis.elapsed,
206        }
207    }
208}
209
210/// Health runner output shared by API, NAPI, and alternate runners.
211///
212/// Runtime-only presentation probes stay explicit so the API boundary, not the
213/// concrete runner, owns the final programmatic report assembly.
214pub struct ProgrammaticHealthRun {
215    /// Engine analysis payload.
216    pub analysis: ProgrammaticHealthAnalysis,
217    /// Non-fatal per-file diagnostics collected during the workspace walk.
218    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
219    /// Environment and project facts that drive next-step suggestions.
220    pub next_step_facts: ProgrammaticHealthNextStepFacts,
221    /// Analysis run id stamped into telemetry metadata when present.
222    pub telemetry_analysis_run_id: Option<String>,
223    /// What became of the narrowing requests the run received.
224    pub request_outcomes: Option<fallow_output::RequestOutcomes>,
225}
226
227/// Runner boundary for programmatic health.
228///
229/// This keeps embedders on the typed API contract while still allowing tests
230/// and host integrations to provide a custom health runner.
231pub trait ProgrammaticHealthRunner {
232    /// Run health analysis for public programmatic options.
233    ///
234    /// # Errors
235    ///
236    /// Returns a structured programmatic error when the concrete runner cannot
237    /// resolve options or complete health analysis.
238    fn run_programmatic_health(
239        &self,
240        options: &ComplexityOptions,
241    ) -> Result<ProgrammaticHealthRun, ProgrammaticError>;
242}
243
244/// Default health runner backed directly by `fallow-engine`.
245///
246/// This runs the command-neutral health pipeline through the engine health
247/// runner without touching the CLI crate: the programmatic
248/// path never groups (`--group-by`), never drives the runtime coverage sidecar,
249/// and never records CLI telemetry, so the runner hooks are inert. This is
250/// the default runner for NAPI and Rust embedders.
251#[derive(Debug, Clone, Copy, Default)]
252pub struct EngineHealthRunner;
253
254impl ProgrammaticHealthRunner for EngineHealthRunner {
255    fn run_programmatic_health(
256        &self,
257        options: &ComplexityOptions,
258    ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
259        let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
260        resolved.install(|| run_programmatic_health_on_engine(&resolved, options))
261    }
262}
263
264fn run_programmatic_health_on_engine(
265    resolved: &ProgrammaticAnalysisContext,
266    options: &ComplexityOptions,
267) -> ProgrammaticResult<ProgrammaticHealthRun> {
268    let health_options = derive_programmatic_health_execution_options(resolved, options);
269    let loaded = fallow_engine::health::UngroupedHealthSession::load(&health_options)
270        .map_err(|error| programmatic_health_error("health", error))?;
271    let workspace_roots = workspace_roots_for_session(resolved, loaded.workspaces())?;
272    let result = loaded
273        .run(&health_options, workspace_roots)
274        .map_err(|error| programmatic_health_error("health", error))?;
275    resolved.record_changed_since_from_runner(result.changed_files_analyzed.as_deref());
276
277    Ok(programmatic_health_run_from_engine_result(
278        result,
279        resolved.request_outcomes(),
280    ))
281}
282
283fn programmatic_health_run_from_engine_result<GroupResolver>(
284    result: fallow_engine::health::HealthAnalysisResult<GroupResolver>,
285    request_outcomes: Option<fallow_output::RequestOutcomes>,
286) -> ProgrammaticHealthRun {
287    let root = result.config.root.clone();
288    let next_step_facts = ProgrammaticHealthNextStepFacts {
289        suggestions_enabled: suggestions_enabled(),
290        offer_setup: setup_pointer_applicable(&root),
291        impact_digest: None,
292        audit_changed: audit_changed_applicable(&root),
293    };
294    ProgrammaticHealthRun {
295        workspace_diagnostics: result.workspace_diagnostics.clone(),
296        analysis: ProgrammaticHealthAnalysis::from_engine(result.without_group_resolver()),
297        next_step_facts,
298        telemetry_analysis_run_id: None,
299        request_outcomes,
300    }
301}
302
303#[cfg(test)]
304pub(super) fn run_health_with_session(
305    options: &ComplexityOptions,
306    resolved: &ProgrammaticAnalysisContext,
307    session: &AnalysisSession,
308    changed_files: Option<&FxHashSet<PathBuf>>,
309) -> ProgrammaticResult<HealthProgrammaticOutput> {
310    run_health_with_session_artifacts(options, resolved, session, changed_files, None, None)
311}
312
313pub(super) fn run_health_with_session_artifacts(
314    options: &ComplexityOptions,
315    resolved: &ProgrammaticAnalysisContext,
316    session: &AnalysisSession,
317    changed_files: Option<&FxHashSet<PathBuf>>,
318    pre_computed_analysis: Option<DeadCodeAnalysisArtifacts>,
319    pre_computed_duplication: Option<DuplicationReport>,
320) -> ProgrammaticResult<HealthProgrammaticOutput> {
321    crate::validate_complexity_options(options)?;
322    let health_options = derive_programmatic_health_execution_options(resolved, options);
323    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
324    let result = fallow_engine::health::run_ungrouped_health_with_session_artifacts(
325        &health_options,
326        workspace_roots,
327        session,
328        changed_files.map(|files| files.iter().cloned().collect()),
329        pre_computed_analysis,
330        pre_computed_duplication,
331    )
332    .map_err(|error| programmatic_health_error("health", error))?;
333    resolved.record_changed_since_from_runner(result.changed_files_analyzed.as_deref());
334
335    Ok(assemble_health_programmatic_output(
336        options,
337        programmatic_health_run_from_engine_result(result, resolved.request_outcomes()),
338    ))
339}
340
341fn programmatic_health_error(
342    command: &str,
343    error: fallow_engine::health::HealthError,
344) -> ProgrammaticError {
345    let (message, exit_code) = match error {
346        fallow_engine::health::HealthError::Message { message, exit_code } => (message, exit_code),
347        fallow_engine::health::HealthError::Printed(exit_code) => {
348            (format!("{command} failed"), exit_code)
349        }
350    };
351    let code = format!(
352        "FALLOW_{}_FAILED",
353        command.replace('-', "_").to_ascii_uppercase()
354    );
355    ProgrammaticError::new(message, exit_code)
356        .with_code(code)
357        .with_context(format!("fallow {command}"))
358        .with_help(format!(
359            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
360        ))
361}
362
363/// Run programmatic health / complexity through the engine-backed runner.
364///
365/// # Errors
366///
367/// Returns a structured programmatic error for invalid options or analysis
368/// failures.
369pub fn run_health(options: &ComplexityOptions) -> ProgrammaticResult<HealthProgrammaticOutput> {
370    run_health_with_runner(options, &EngineHealthRunner)
371}
372
373#[must_use]
374fn derive_programmatic_health_execution_options<'a>(
375    resolved: &'a ProgrammaticAnalysisContext,
376    options: &'a ComplexityOptions,
377) -> fallow_engine::health::HealthExecutionOptions<'a> {
378    let run = crate::derive_complexity_run_options(options);
379
380    fallow_engine::health::HealthExecutionOptions {
381        root: resolved.root(),
382        config_path: resolved.config_path(),
383        output: OutputFormat::Human,
384        no_cache: resolved.no_cache(),
385        threads: resolved.threads(),
386        quiet: true,
387        complexity_breakdown: run.complexity_breakdown,
388        thresholds: crate::thresholds_to_engine(run.thresholds),
389        top: run.top,
390        sort: crate::complexity_sort_to_engine(run.sort),
391        production: resolved.production_override().unwrap_or(false),
392        production_override: resolved.production_override(),
393        allow_remote_extends: resolved.allow_remote_extends(),
394        changed_since: resolved.changed_since(),
395        diff_index: resolved.diff_index(),
396        use_shared_diff_index: false,
397        workspace: resolved.workspace(),
398        changed_workspaces: resolved.changed_workspaces(),
399        baseline: None,
400        save_baseline: None,
401        baseline_mode: fallow_engine::baseline::HealthBaselineMode::Count,
402        baseline_mode_explicit: false,
403        complexity: run.sections.complexity,
404        file_scores: run.sections.file_scores,
405        coverage_gaps: run.sections.coverage_gaps,
406        config_activates_coverage_gaps: !run.sections.any_section,
407        hotspots: run.sections.hotspots,
408        ownership: run.sections.ownership,
409        targets: run.sections.targets,
410        css: run.css,
411        css_deep: run.css_deep,
412        force_full: run.sections.force_full,
413        score_only_output: run.sections.score_only_output,
414        enforce_coverage_gap_gate: true,
415        effort: run.effort.map(crate::target_effort_to_output),
416        score: run.sections.score,
417        gates: fallow_engine::health::HealthGateOptions::default(),
418        since: run.since,
419        min_commits: run.min_commits,
420        explain: resolved.explain_enabled(),
421        summary: false,
422        save_snapshot: None,
423        trend: false,
424        coverage_inputs: crate::coverage_inputs_to_engine(run.coverage_inputs),
425        performance: false,
426        runtime_coverage: None,
427        churn_file: None,
428        analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
429        group_by: None,
430        group_filter: None,
431        trend_from: None,
432        scope: None,
433        ownership_emails: run
434            .ownership_emails
435            .map(crate::ownership_email_mode_to_config),
436    }
437}
438
439/// Run programmatic health / complexity and return typed API output.
440///
441/// The runner is a seam for tests and hosts. [`EngineHealthRunner`] is the
442/// default. The runner supplies typed analysis and runtime facts. This API
443/// crate owns the final programmatic report assembly.
444///
445/// # Errors
446///
447/// Returns a structured programmatic error for invalid options or runner
448/// failures. When no section is selected and the environment or
449/// `typeAware.enabled` turns on type-aware analysis that the options leave
450/// off, it returns [`TYPE_AWARE_CONFIG_UNSUPPORTED`].
451pub fn run_complexity_with_runner(
452    options: &ComplexityOptions,
453    runner: &impl ProgrammaticHealthRunner,
454) -> ProgrammaticResult<HealthProgrammaticOutput> {
455    crate::validate_complexity_options(options)?;
456    reject_health_config_type_aware(options)?;
457    run_validated_complexity(options, runner)
458}
459
460/// The health section of `fallow audit`. Audit resolves type-aware analysis
461/// with its own precedence, so the `fallow health` check does not apply.
462pub(super) fn run_health_for_audit(
463    options: &ComplexityOptions,
464) -> ProgrammaticResult<HealthProgrammaticOutput> {
465    crate::validate_complexity_options(options)?;
466    run_validated_complexity(options, &EngineHealthRunner)
467}
468
469/// `fallow health` runs type coupling from config only when no section is
470/// selected (`health_type_coupling_is_default_section` in the CLI).
471fn reject_health_config_type_aware(options: &ComplexityOptions) -> ProgrammaticResult<()> {
472    if options.css || crate::derive_complexity_options(options).any_section {
473        return Ok(());
474    }
475    let root = crate::analysis_context::resolve_analysis_root(options.analysis.root.as_deref())?;
476    crate::analysis_context::validate_analysis_config_path(
477        options.analysis.config_path.as_deref(),
478    )?;
479    type_aware_config::reject_config_type_aware_at(
480        &options.analysis.type_aware,
481        type_aware_config::ConfigLocation {
482            root: &root,
483            config_path: options.analysis.config_path.as_deref(),
484            allow_remote_extends: options.analysis.allow_remote_extends,
485        },
486        type_aware_config::TypeAwareCommand::Analysis,
487    )
488}
489
490fn run_validated_complexity(
491    options: &ComplexityOptions,
492    runner: &impl ProgrammaticHealthRunner,
493) -> ProgrammaticResult<HealthProgrammaticOutput> {
494    crate::analysis_context::ensure_options_not_cancelled(&options.analysis, "health analysis")?;
495    Ok(assemble_health_programmatic_output(
496        options,
497        runner.run_programmatic_health(options)?,
498    ))
499}
500
501fn assemble_health_programmatic_output(
502    options: &ComplexityOptions,
503    run: ProgrammaticHealthRun,
504) -> HealthProgrammaticOutput {
505    let ProgrammaticHealthRun {
506        analysis,
507        workspace_diagnostics,
508        next_step_facts,
509        telemetry_analysis_run_id,
510        request_outcomes,
511    } = run;
512    let root = analysis.root.clone();
513    let next_steps =
514        fallow_output::build_health_next_steps(fallow_output::build_health_next_steps_input(
515            &analysis.report,
516            next_step_facts.suggestions_enabled,
517            next_step_facts.offer_setup,
518            next_step_facts.impact_digest,
519            next_step_facts.audit_changed,
520            None,
521        ));
522    HealthProgrammaticOutput {
523        report: analysis.report,
524        grouping: analysis.grouping,
525        root,
526        elapsed: analysis.elapsed,
527        explain: options.analysis.explain,
528        workspace_diagnostics,
529        next_steps,
530        telemetry_analysis_run_id,
531        request_outcomes,
532    }
533}
534
535/// Alias for [`run_complexity_with_runner`] with a product-oriented name.
536///
537/// # Errors
538///
539/// Returns the same structured errors as [`run_complexity_with_runner`].
540pub fn run_health_with_runner(
541    options: &ComplexityOptions,
542    runner: &impl ProgrammaticHealthRunner,
543) -> ProgrammaticResult<HealthProgrammaticOutput> {
544    run_complexity_with_runner(options, runner)
545}
546
547#[cfg(test)]
548mod cancellation_tests;
549#[cfg(test)]
550mod tests;