Skip to main content

fallow_api/runtime/
audit.rs

1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use fallow_config::{AuditGate, ProductionAnalysis};
5use fallow_engine::{
6    dead_code::DeadCodeAnalysisArtifacts,
7    project_analysis::ProjectAnalysisArtifactOptions,
8    project_config::ProjectConfigOptions,
9    repo_refs::{self, ResolvedAuditBase, TemporaryBaseWorktree},
10    session::AnalysisSession,
11};
12use fallow_output::build_audit_next_steps;
13use fallow_types::{envelope::AuditIntroduced, output::NextStep, output_format::OutputFormat};
14use rustc_hash::FxHashSet;
15
16use crate::{
17    AnalysisOptions, AuditAttribution, AuditOptions, AuditProgrammaticKeySnapshot,
18    AuditProgrammaticOutput, AuditSummary, AuditVerdict, ComplexityOptions, DeadCodeFilters,
19    DeadCodeOptions, DuplicationOptions, ProgrammaticError,
20    analysis_context::{
21        ProgrammaticAnalysisContext, changed_files_for_run,
22        resolve_programmatic_analysis_context_deferred_workspace,
23    },
24};
25
26use super::{
27    ProgrammaticResult, health_may_consume_dead_code_artifacts,
28    health_may_consume_duplication_report, resolve_effective_production_modes, root_envelope_mode,
29    run_dead_code, run_duplication, run_health, run_health_with_session_artifacts,
30};
31
32/// Run changed-code audit through typed programmatic runners.
33///
34/// # Errors
35///
36/// Returns a structured error for invalid options, base-ref discovery failures,
37/// unsupported CLI-only audit surfaces, or analysis failures.
38pub fn run_audit(options: &AuditOptions) -> ProgrammaticResult<AuditProgrammaticOutput> {
39    validate_audit_api_options(options)?;
40    let start = Instant::now();
41    let resolved_base = resolve_audit_base_ref(options)?;
42    let analysis = analysis_options_for_audit(options, &resolved_base.git_ref);
43    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&analysis)?;
44    let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
45    let changed_files_count = changed_files.len();
46
47    if changed_files.is_empty() {
48        return Ok(empty_audit_output(
49            options,
50            resolved_base,
51            resolved.root(),
52            changed_files_count,
53            start.elapsed(),
54        ));
55    }
56
57    let mut head =
58        run_audit_subanalyses_with_context(options, &analysis, &resolved, Some(&changed_files))?;
59    let runtime_base_snapshot = if matches!(options.gate, AuditGate::NewOnly) {
60        Some(compute_base_snapshot(options, &resolved_base.git_ref)?)
61    } else {
62        None
63    };
64    let config = load_programmatic_audit_config(&resolved)?;
65    let mut comparison =
66        build_programmatic_audit_comparison(&head, &config, runtime_base_snapshot.as_ref());
67    demote_preexisting_dupe_introductions(&mut comparison, &head, &resolved_base.git_ref);
68    let summary = build_programmatic_audit_summary(&head, &comparison);
69    let attribution =
70        comparison_attribution(options.gate, &comparison, runtime_base_snapshot.is_some());
71    let verdict = comparison_verdict(
72        options.gate,
73        &summary,
74        &head.duplication,
75        &head.complexity,
76        &config,
77        &comparison,
78    );
79    if runtime_base_snapshot.is_some() {
80        comparison.annotate_typed_findings(
81            &mut head.dead_code.output.results,
82            &mut head.complexity.report,
83        );
84        for (group, introduced) in head
85            .duplication
86            .output
87            .report
88            .clone_groups
89            .iter_mut()
90            .zip(comparison.dupes.introduced())
91        {
92            group.introduced = Some(AuditIntroduced(introduced));
93        }
94    }
95    let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
96    let base_snapshot = runtime_base_snapshot.map(|snapshot| snapshot.public);
97
98    Ok(AuditProgrammaticOutput {
99        verdict,
100        summary,
101        attribution,
102        changed_files_count,
103        base_ref: resolved_base.git_ref,
104        base_description: resolved_base.description,
105        head_sha: repo_refs::short_head_sha(resolved.root()),
106        elapsed: start.elapsed(),
107        base_snapshot_skipped: None,
108        base_snapshot,
109        dead_code: Some(head.dead_code),
110        duplication: Some(head.duplication),
111        complexity: Some(head.complexity),
112        next_steps,
113        envelope_mode: root_envelope_mode(),
114        telemetry_analysis_run_id: None,
115    })
116}
117
118fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
119    if let Err(err) =
120        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
121    {
122        return Err(ProgrammaticError::new(err, 2)
123            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
124            .with_context("audit.coverageRoot"));
125    }
126    if options.runtime_coverage.is_some() {
127        return Err(ProgrammaticError::new(
128            "programmatic audit does not yet support runtime coverage; use the CLI path",
129            2,
130        )
131        .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
132        .with_context("audit.runtimeCoverage"));
133    }
134    Ok(())
135}
136
137pub(super) fn resolve_audit_base_ref(
138    options: &AuditOptions,
139) -> ProgrammaticResult<ResolvedAuditBase> {
140    if let Some(ref_str) = options
141        .base
142        .as_deref()
143        .or(options.analysis.changed_since.as_deref())
144    {
145        validate_git_ref(ref_str, "audit.base")?;
146        return Ok(ResolvedAuditBase {
147            git_ref: (*ref_str).to_string(),
148            description: None,
149        });
150    }
151    if let Some(env_ref) = audit_base_env_override() {
152        validate_git_ref(&env_ref, "FALLOW_AUDIT_BASE")?;
153        return Ok(ResolvedAuditBase {
154            description: Some(format!("FALLOW_AUDIT_BASE={env_ref}")),
155            git_ref: env_ref,
156        });
157    }
158    let root = options
159        .analysis
160        .root
161        .clone()
162        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
163    repo_refs::auto_detect_audit_base_ref(&root).ok_or_else(|| {
164        ProgrammaticError::new(
165            "could not detect base branch. Set audit.base to specify the comparison target",
166            2,
167        )
168        .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
169        .with_context("audit.base")
170    })
171}
172
173fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
174    AnalysisOptions {
175        changed_since: Some(base_ref.to_string()),
176        production: options.production,
177        production_override: options.production.then_some(true),
178        ..options.analysis.clone()
179    }
180}
181
182fn analysis_with_production(
183    analysis: &AnalysisOptions,
184    production_override: Option<bool>,
185) -> AnalysisOptions {
186    AnalysisOptions {
187        production: production_override.unwrap_or(analysis.production),
188        production_override: production_override.or(analysis.production_override),
189        ..analysis.clone()
190    }
191}
192
193fn empty_audit_output(
194    options: &AuditOptions,
195    base: ResolvedAuditBase,
196    root: &Path,
197    changed_files_count: usize,
198    elapsed: std::time::Duration,
199) -> AuditProgrammaticOutput {
200    AuditProgrammaticOutput {
201        verdict: AuditVerdict::Pass,
202        summary: AuditSummary {
203            dead_code_issues: 0,
204            dead_code_has_errors: false,
205            complexity_findings: 0,
206            max_cyclomatic: None,
207            duplication_clone_groups: 0,
208        },
209        attribution: AuditAttribution {
210            gate: options.gate,
211            ..AuditAttribution::default()
212        },
213        changed_files_count,
214        base_ref: base.git_ref,
215        base_description: base.description,
216        head_sha: repo_refs::short_head_sha(root),
217        elapsed,
218        base_snapshot_skipped: None,
219        base_snapshot: None,
220        dead_code: None,
221        duplication: None,
222        complexity: None,
223        next_steps: Vec::new(),
224        envelope_mode: root_envelope_mode(),
225        telemetry_analysis_run_id: None,
226    }
227}
228
229struct AuditSubanalyses {
230    dead_code: crate::DeadCodeProgrammaticOutput,
231    duplication: crate::DuplicationProgrammaticOutput,
232    complexity: crate::HealthProgrammaticOutput,
233}
234
235struct AuditRuntimeKeySnapshot {
236    public: AuditProgrammaticKeySnapshot,
237    styling: FxHashSet<String>,
238}
239
240struct AuditSubanalysisOptions {
241    dead_code: DeadCodeOptions,
242    duplication: DuplicationOptions,
243    complexity: ComplexityOptions,
244}
245
246fn audit_subanalysis_options(
247    options: &AuditOptions,
248    analysis: &AnalysisOptions,
249) -> AuditSubanalysisOptions {
250    AuditSubanalysisOptions {
251        dead_code: DeadCodeOptions {
252            analysis: analysis_with_production(analysis, options.production_dead_code),
253            filters: DeadCodeFilters::default(),
254            files: Vec::new(),
255            include_entry_exports: options.include_entry_exports,
256        },
257        duplication: DuplicationOptions {
258            analysis: analysis_with_production(analysis, options.production_dupes),
259            ..DuplicationOptions::default()
260        },
261        complexity: ComplexityOptions {
262            analysis: analysis_with_production(analysis, options.production_health),
263            max_crap: options.max_crap,
264            complexity: true,
265            css: options.css.unwrap_or(true),
266            css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
267            coverage: options.coverage.clone(),
268            coverage_root: options.coverage_root.clone(),
269            ..ComplexityOptions::default()
270        },
271    }
272}
273
274fn run_audit_subanalyses(
275    options: &AuditOptions,
276    analysis: &AnalysisOptions,
277    changed_files: Option<&FxHashSet<PathBuf>>,
278) -> ProgrammaticResult<AuditSubanalyses> {
279    let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
280    run_audit_subanalyses_with_context(options, analysis, &resolved, changed_files)
281}
282
283fn run_audit_subanalyses_with_context(
284    options: &AuditOptions,
285    analysis: &AnalysisOptions,
286    resolved: &ProgrammaticAnalysisContext,
287    changed_files: Option<&FxHashSet<PathBuf>>,
288) -> ProgrammaticResult<AuditSubanalyses> {
289    let subanalysis_options = audit_subanalysis_options(options, analysis);
290    let production_modes = resolve_effective_production_modes(
291        resolved,
292        options.production_dead_code,
293        options.production_health,
294        options.production_dupes,
295    )?;
296
297    if production_modes.dead_code == production_modes.dupes
298        && production_modes.dead_code == production_modes.health
299    {
300        return run_shared_project_audit_subanalyses(&subanalysis_options, changed_files);
301    }
302
303    if production_modes.dead_code == production_modes.health {
304        return run_shared_dead_code_health_audit_subanalyses(&subanalysis_options, changed_files);
305    }
306
307    if production_modes.dead_code == production_modes.dupes {
308        return run_shared_dead_code_dupes_audit_subanalyses(&subanalysis_options, changed_files);
309    }
310
311    Ok(AuditSubanalyses {
312        dead_code: run_dead_code(&subanalysis_options.dead_code)?,
313        duplication: run_duplication(&subanalysis_options.duplication)?,
314        complexity: run_health(&subanalysis_options.complexity)?,
315    })
316}
317
318fn run_shared_project_audit_subanalyses(
319    options: &AuditSubanalysisOptions,
320    changed_files: Option<&FxHashSet<PathBuf>>,
321) -> ProgrammaticResult<AuditSubanalyses> {
322    let resolved =
323        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
324    resolved.install(|| {
325        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
326        run_all_audit_subanalyses_with_project_artifacts(
327            &options.dead_code,
328            &options.duplication,
329            &options.complexity,
330            &resolved,
331            &session,
332            changed_files,
333        )
334    })
335}
336
337fn run_shared_dead_code_health_audit_subanalyses(
338    options: &AuditSubanalysisOptions,
339    changed_files: Option<&FxHashSet<PathBuf>>,
340) -> ProgrammaticResult<AuditSubanalyses> {
341    let resolved =
342        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
343    resolved.install(|| {
344        let dead_code_options = &options.dead_code;
345        let duplication_options = &options.duplication;
346        let complexity_options = &options.complexity;
347        let session = super::dead_code::load_dead_code_session(dead_code_options, &resolved)?;
348        let (dead_code, complexity) = run_dead_code_and_health_with_session(
349            dead_code_options,
350            complexity_options,
351            &resolved,
352            &session,
353            changed_files,
354        )?;
355        Ok(AuditSubanalyses {
356            dead_code,
357            duplication: run_duplication(duplication_options)?,
358            complexity,
359        })
360    })
361}
362
363fn run_shared_dead_code_dupes_audit_subanalyses(
364    options: &AuditSubanalysisOptions,
365    changed_files: Option<&FxHashSet<PathBuf>>,
366) -> ProgrammaticResult<AuditSubanalyses> {
367    let resolved =
368        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
369    resolved.install(|| {
370        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
371        let (dead_code, duplication, _, _) =
372            run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
373                dead_code_options: &options.dead_code,
374                duplication_options: &options.duplication,
375                resolved: &resolved,
376                session: &session,
377                changed_files,
378                retain_dead_code_artifacts: false,
379                retain_duplication_artifacts: false,
380            })?;
381        Ok(AuditSubanalyses {
382            dead_code,
383            duplication,
384            complexity: run_health(&options.complexity)?,
385        })
386    })
387}
388
389fn run_dead_code_and_duplication_with_project_artifacts(
390    input: ProjectArtifactAuditInput<'_>,
391) -> ProgrammaticResult<(
392    crate::DeadCodeProgrammaticOutput,
393    crate::DuplicationProgrammaticOutput,
394    Option<DeadCodeAnalysisArtifacts>,
395    Option<fallow_engine::duplicates::DuplicationReport>,
396)> {
397    let dupes_config = super::duplication::build_dupes_config(
398        input.duplication_options,
399        &input.session.config().duplicates,
400    );
401    let section_start = Instant::now();
402    let project = input
403        .session
404        .analyze_project_with_artifacts(
405            &dupes_config,
406            ProjectAnalysisArtifactOptions {
407                retain_complexity_artifacts: input.retain_dead_code_artifacts,
408                retain_graph: input.retain_dead_code_artifacts,
409                changed_files: input.changed_files.cloned(),
410                collect_source_fingerprints: false,
411            },
412        )
413        .map_err(|err| {
414            ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
415                .with_code("FALLOW_AUDIT_FAILED")
416                .with_context("audit")
417        })?;
418    let duplication_artifacts = input
419        .retain_duplication_artifacts
420        .then(|| project.duplication.clone());
421    let dead_code = super::dead_code::run_dead_code_from_artifacts(
422        input.dead_code_options,
423        input.resolved,
424        input.session,
425        input.changed_files,
426        project.dead_code,
427        section_start,
428    )?;
429    let duplication = super::duplication::run_duplication_report_with_session(
430        input.duplication_options,
431        input.resolved,
432        input.session,
433        project.duplication,
434        section_start,
435    )?;
436    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
437        output: dead_code,
438        artifacts,
439    } = dead_code;
440    let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
441    Ok((
442        dead_code,
443        duplication,
444        dead_code_artifacts,
445        duplication_artifacts,
446    ))
447}
448
449#[derive(Clone, Copy)]
450struct ProjectArtifactAuditInput<'a> {
451    dead_code_options: &'a DeadCodeOptions,
452    duplication_options: &'a DuplicationOptions,
453    resolved: &'a ProgrammaticAnalysisContext,
454    session: &'a AnalysisSession,
455    changed_files: Option<&'a FxHashSet<PathBuf>>,
456    retain_dead_code_artifacts: bool,
457    retain_duplication_artifacts: bool,
458}
459
460fn run_all_audit_subanalyses_with_project_artifacts(
461    dead_code_options: &DeadCodeOptions,
462    duplication_options: &DuplicationOptions,
463    complexity_options: &ComplexityOptions,
464    resolved: &ProgrammaticAnalysisContext,
465    session: &AnalysisSession,
466    changed_files: Option<&FxHashSet<PathBuf>>,
467) -> ProgrammaticResult<AuditSubanalyses> {
468    let retain_dead_code_artifacts =
469        health_may_consume_dead_code_artifacts(complexity_options, session.config());
470    let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
471    let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
472        run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
473            dead_code_options,
474            duplication_options,
475            resolved,
476            session,
477            changed_files,
478            retain_dead_code_artifacts,
479            retain_duplication_artifacts,
480        })?;
481    let complexity = run_health_with_session_artifacts(
482        complexity_options,
483        resolved,
484        session,
485        changed_files,
486        dead_code_artifacts,
487        duplication_artifacts,
488    )?;
489    Ok(AuditSubanalyses {
490        dead_code,
491        duplication,
492        complexity,
493    })
494}
495
496fn run_dead_code_and_health_with_session(
497    dead_code_options: &DeadCodeOptions,
498    complexity_options: &ComplexityOptions,
499    resolved: &ProgrammaticAnalysisContext,
500    session: &AnalysisSession,
501    changed_files: Option<&FxHashSet<PathBuf>>,
502) -> ProgrammaticResult<(
503    crate::DeadCodeProgrammaticOutput,
504    crate::HealthProgrammaticOutput,
505)> {
506    let reuse_dead_code_artifacts =
507        health_may_consume_dead_code_artifacts(complexity_options, session.config());
508    let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
509        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
510            dead_code_options,
511            resolved,
512            session,
513            changed_files,
514            |_| {},
515            Instant::now(),
516        )?;
517        (dead_code.output, Some(dead_code.artifacts))
518    } else {
519        (
520            super::dead_code::run_dead_code_with_session(
521                dead_code_options,
522                resolved,
523                session,
524                changed_files,
525                |_| {},
526                Instant::now(),
527            )?,
528            None,
529        )
530    };
531    let complexity = run_health_with_session_artifacts(
532        complexity_options,
533        resolved,
534        session,
535        changed_files,
536        dead_code_artifacts,
537        None,
538    )?;
539    Ok((dead_code, complexity))
540}
541
542fn load_programmatic_audit_config(
543    resolved: &ProgrammaticAnalysisContext,
544) -> ProgrammaticResult<fallow_config::ResolvedConfig> {
545    fallow_engine::project_config::config_for_project_analysis(
546        resolved.root(),
547        resolved.config_path().as_deref(),
548        ProjectConfigOptions {
549            output: OutputFormat::Json,
550            no_cache: resolved.no_cache(),
551            threads: resolved.threads(),
552            production_override: resolved.production_override(),
553            quiet: true,
554            analysis: ProductionAnalysis::DeadCode,
555            allow_remote_extends: resolved.allow_remote_extends(),
556        },
557    )
558    .map(|project| project.config)
559    .map_err(|err| {
560        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
561            .with_code("FALLOW_CONFIG_LOAD_FAILED")
562            .with_context("analysis.configPath")
563    })
564}
565
566fn build_programmatic_audit_comparison(
567    analyses: &AuditSubanalyses,
568    config: &fallow_config::ResolvedConfig,
569    base: Option<&AuditRuntimeKeySnapshot>,
570) -> crate::audit_keys::AuditComparison {
571    let dupe_keys = analyses
572        .duplication
573        .output
574        .report
575        .clone_groups
576        .iter()
577        .map(|group| crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root))
578        .collect();
579    let styling_keys = analyses
580        .complexity
581        .report
582        .styling_findings
583        .iter()
584        .map(|finding| crate::audit_keys::styling_finding_key(finding, &analyses.complexity.root))
585        .collect();
586    crate::audit_keys::AuditComparison::build(crate::audit_keys::AuditComparisonInput {
587        results: &analyses.dead_code.output.results,
588        config,
589        root: &analyses.dead_code.root,
590        health: &analyses.complexity.report,
591        health_root: &analyses.complexity.root,
592        dupe_keys,
593        styling_keys,
594        base_dead_code: base.map(|snapshot| &snapshot.public.dead_code),
595        base_health: base.map(|snapshot| &snapshot.public.health),
596        base_dupes: base.map(|snapshot| &snapshot.public.dupes),
597        base_styling: base.map(|snapshot| &snapshot.styling),
598    })
599}
600
601/// Demote introduced clone groups whose instances contain no added lines from
602/// the merge-base worktree diff: no instance range contains an added line, so
603/// the changeset did not write the duplicated text and only the group's
604/// attribution key changed because the changeset
605/// removed code elsewhere. Keeps the new-only gate from failing a
606/// clone-removal refactor on duplication it did not write (issue #2164).
607fn demote_preexisting_dupe_introductions(
608    comparison: &mut crate::audit_keys::AuditComparison,
609    analyses: &AuditSubanalyses,
610    base_ref: &str,
611) {
612    if comparison.dupes.introduced_count() == 0 {
613        return;
614    }
615    let root = &analyses.duplication.root;
616    let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) else {
617        return;
618    };
619    let index = fallow_output::DiffIndex::from_unified_diff(&diff);
620    let demote = crate::audit_keys::preexisting_dupe_group_keys(
621        analyses
622            .duplication
623            .output
624            .report
625            .clone_groups
626            .iter()
627            .map(|group| &group.group),
628        root,
629        &index,
630    );
631    comparison.dupes.demote_introductions(&demote);
632}
633
634fn build_programmatic_audit_summary(
635    analyses: &AuditSubanalyses,
636    comparison: &crate::audit_keys::AuditComparison,
637) -> AuditSummary {
638    let dead_code_issues = comparison.dead_code.visible_count();
639    AuditSummary {
640        dead_code_issues,
641        dead_code_has_errors: comparison.dead_code.has_errors(),
642        complexity_findings: analyses.complexity.report.findings.len(),
643        max_cyclomatic: analyses
644            .complexity
645            .report
646            .findings
647            .iter()
648            .map(|finding| finding.cyclomatic)
649            .max(),
650        duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
651    }
652}
653
654fn styling_finding_gates(rules: &fallow_config::RulesConfig, code: &str) -> bool {
655    let severity = match code {
656        "css-token-drift" => rules.css_token_drift,
657        "css-duplicate-block" => rules.css_duplicate_block,
658        "css-selector-complexity" => rules.css_selector_complexity,
659        "css-dead-surface" => rules.css_dead_surface,
660        "css-broken-reference" => rules.css_broken_reference,
661        _ => fallow_config::Severity::Warn,
662    };
663    severity == fallow_config::Severity::Error
664}
665
666fn comparison_verdict(
667    gate: AuditGate,
668    summary: &AuditSummary,
669    duplication: &crate::DuplicationProgrammaticOutput,
670    complexity: &crate::HealthProgrammaticOutput,
671    config: &fallow_config::ResolvedConfig,
672    comparison: &crate::audit_keys::AuditComparison,
673) -> AuditVerdict {
674    let new_only = matches!(gate, AuditGate::NewOnly);
675    let dead_code_errors = if new_only {
676        comparison.dead_code.has_introduced_errors()
677    } else {
678        comparison.dead_code.has_errors()
679    };
680    let dead_code_warnings = if new_only {
681        comparison.dead_code.has_introduced_warnings()
682    } else {
683        comparison
684            .dead_code
685            .records()
686            .iter()
687            .any(|record| record.effective_severity == fallow_config::Severity::Warn)
688    };
689    let complexity_findings = if new_only {
690        comparison.health.introduced_count()
691    } else {
692        summary.complexity_findings
693    };
694    let styling_errors = complexity
695        .report
696        .styling_findings
697        .iter()
698        .zip(comparison.styling.introduced())
699        .any(|(finding, introduced)| {
700            (!new_only || introduced) && styling_finding_gates(&config.rules, &finding.code)
701        });
702    if dead_code_errors || complexity_findings > 0 || styling_errors {
703        return AuditVerdict::Fail;
704    }
705    let duplication_findings = if new_only {
706        comparison.dupes.introduced_count()
707    } else {
708        summary.duplication_clone_groups
709    };
710    if duplication_findings > 0 {
711        let pct = duplication.output.report.stats.duplication_percentage;
712        if duplication.threshold > 0.0 && pct > duplication.threshold {
713            return AuditVerdict::Fail;
714        }
715        return AuditVerdict::Warn;
716    }
717    if dead_code_warnings {
718        return AuditVerdict::Warn;
719    }
720    AuditVerdict::Pass
721}
722
723fn comparison_attribution(
724    gate: AuditGate,
725    comparison: &crate::audit_keys::AuditComparison,
726    has_base: bool,
727) -> AuditAttribution {
728    if !has_base {
729        return AuditAttribution {
730            gate,
731            ..AuditAttribution::default()
732        };
733    }
734    AuditAttribution {
735        gate,
736        dead_code_introduced: comparison.dead_code.introduced_count(),
737        dead_code_inherited: comparison.dead_code.inherited_count(),
738        complexity_introduced: comparison.health.introduced_count(),
739        complexity_inherited: comparison.health.inherited_count(),
740        duplication_introduced: comparison.dupes.introduced_count(),
741        duplication_inherited: comparison.dupes.inherited_count(),
742    }
743}
744
745fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditRuntimeKeySnapshot {
746    let styling =
747        crate::audit_keys::styling_keys(&analyses.complexity.report, &analyses.complexity.root);
748    let mut health =
749        crate::audit_keys::health_keys(&analyses.complexity.report, &analyses.complexity.root);
750    health.extend(styling.iter().cloned());
751    AuditRuntimeKeySnapshot {
752        public: AuditProgrammaticKeySnapshot {
753            dead_code: crate::audit_keys::dead_code_keys(
754                &analyses.dead_code.output.results,
755                &analyses.dead_code.root,
756            ),
757            health,
758            dupes: analyses
759                .duplication
760                .output
761                .report
762                .clone_groups
763                .iter()
764                .map(|group| {
765                    crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
766                })
767                .collect(),
768        },
769        styling,
770    }
771}
772
773fn compute_base_snapshot(
774    options: &AuditOptions,
775    base_ref: &str,
776) -> ProgrammaticResult<AuditRuntimeKeySnapshot> {
777    let current_root = analysis_root_from_options(options)?;
778    let worktree = TemporaryBaseWorktree::create(&current_root, base_ref).map_err(|err| {
779        ProgrammaticError::new(err.to_string(), 2)
780            .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
781            .with_context("audit.base")
782    })?;
783    let base_root = repo_refs::base_analysis_root(&current_root, worktree.path());
784    let current_config_path = options
785        .analysis
786        .config_path
787        .clone()
788        .or_else(|| fallow_config::FallowConfig::find_config_path(&current_root));
789    let base_analysis = AnalysisOptions {
790        root: Some(base_root),
791        config_path: current_config_path,
792        changed_since: None,
793        explain: false,
794        ..options.analysis.clone()
795    };
796    let base = run_audit_subanalyses(options, &base_analysis, None)?;
797    Ok(snapshot_from_analyses(&base))
798}
799
800fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
801    match options.analysis.root.clone() {
802        Some(root) => Ok(root),
803        None => std::env::current_dir().map_err(|err| {
804            ProgrammaticError::new(
805                format!("failed to resolve current working directory: {err}"),
806                2,
807            )
808            .with_code("FALLOW_CWD_UNAVAILABLE")
809            .with_context("analysis.root")
810        }),
811    }
812}
813
814fn audit_next_steps(
815    dead_code: &crate::DeadCodeProgrammaticOutput,
816    complexity: &crate::HealthProgrammaticOutput,
817) -> Vec<NextStep> {
818    let input = fallow_output::build_audit_next_steps_input(
819        Some((&dead_code.output.results, dead_code.root.as_path())),
820        Some(&complexity.report),
821        crate::next_steps::suggestions_enabled(),
822    );
823    build_audit_next_steps(&input)
824}
825
826fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
827    fallow_engine::validate::validate_git_ref(value)
828        .map(|_| ())
829        .map_err(|err| {
830            ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
831                .with_code("FALLOW_INVALID_GIT_REF")
832                .with_context(context)
833        })
834}
835
836fn audit_base_env_override() -> Option<String> {
837    std::env::var("FALLOW_AUDIT_BASE")
838        .ok()
839        .map(|value| value.trim().to_string())
840        .filter(|value| !value.is_empty())
841}
842
843#[cfg(test)]
844mod tests {
845    use std::process::Command;
846
847    use fallow_config::{AuditGate, FallowConfig, HealthConfig};
848    use fallow_types::output_format::OutputFormat;
849
850    use super::*;
851
852    fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
853        FallowConfig {
854            health: HealthConfig {
855                max_crap,
856                ..HealthConfig::default()
857            },
858            ..FallowConfig::default()
859        }
860        .resolve(
861            std::env::temp_dir().join("fallow-api-runtime-test"),
862            OutputFormat::Json,
863            1,
864            true,
865            true,
866            None,
867        )
868    }
869
870    #[test]
871    fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
872        let options = ComplexityOptions {
873            complexity: true,
874            ..ComplexityOptions::default()
875        };
876        let config = resolved_config_with_max_crap(0.0);
877
878        assert!(!health_may_consume_dead_code_artifacts(&options, &config));
879    }
880
881    #[test]
882    fn audit_health_artifact_reuse_tracks_config_max_crap() {
883        let options = ComplexityOptions {
884            complexity: true,
885            ..ComplexityOptions::default()
886        };
887        let config = resolved_config_with_max_crap(30.0);
888
889        assert!(health_may_consume_dead_code_artifacts(&options, &config));
890    }
891
892    #[test]
893    fn audit_health_artifact_reuse_tracks_file_score_inputs() {
894        let config = resolved_config_with_max_crap(0.0);
895        for options in [
896            ComplexityOptions {
897                file_scores: true,
898                ..ComplexityOptions::default()
899            },
900            ComplexityOptions {
901                coverage_gaps: true,
902                ..ComplexityOptions::default()
903            },
904            ComplexityOptions {
905                targets: true,
906                ..ComplexityOptions::default()
907            },
908            ComplexityOptions {
909                score: true,
910                ..ComplexityOptions::default()
911            },
912            ComplexityOptions {
913                max_crap: Some(30.0),
914                complexity: true,
915                ..ComplexityOptions::default()
916            },
917        ] {
918            assert!(health_may_consume_dead_code_artifacts(&options, &config));
919        }
920    }
921
922    #[test]
923    fn audit_health_duplication_reuse_tracks_score_and_targets() {
924        for options in [
925            ComplexityOptions {
926                score: true,
927                ..ComplexityOptions::default()
928            },
929            ComplexityOptions {
930                targets: true,
931                ..ComplexityOptions::default()
932            },
933        ] {
934            assert!(health_may_consume_duplication_report(&options));
935        }
936
937        assert!(!health_may_consume_duplication_report(&ComplexityOptions {
938            complexity: true,
939            ..ComplexityOptions::default()
940        }));
941    }
942
943    #[test]
944    fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
945        let project = audit_fixture();
946        let output = run_audit(&AuditOptions {
947            analysis: AnalysisOptions {
948                root: Some(project.path().to_path_buf()),
949                no_cache: true,
950                explain: true,
951                ..AnalysisOptions::default()
952            },
953            base: Some("HEAD".to_string()),
954            gate: AuditGate::NewOnly,
955            ..AuditOptions::default()
956        })
957        .expect("audit output");
958
959        assert_eq!(output.verdict, AuditVerdict::Fail);
960        assert_eq!(output.summary.dead_code_issues, 1);
961        assert_eq!(output.attribution.dead_code_introduced, 1);
962        assert!(output.base_snapshot.is_some());
963
964        let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
965        assert_eq!(json["schema_version"], fallow_output::AUDIT_SCHEMA_VERSION);
966        assert_eq!(
967            json["dead_code"]["unused_files"][0]["path"],
968            "src/feature.ts"
969        );
970        assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
971    }
972
973    #[test]
974    fn run_audit_warn_only_dead_code_matches_cli_verdict_semantics() {
975        let project = audit_fixture();
976        std::fs::write(
977            project.path().join(".fallowrc.json"),
978            r#"{"rules":{"unused-files":"warn"}}"#,
979        )
980        .expect("write config");
981
982        let output = run_audit(&AuditOptions {
983            analysis: AnalysisOptions {
984                root: Some(project.path().to_path_buf()),
985                no_cache: true,
986                ..AnalysisOptions::default()
987            },
988            base: Some("HEAD".to_string()),
989            gate: AuditGate::All,
990            ..AuditOptions::default()
991        })
992        .expect("audit output");
993
994        assert_eq!(output.verdict, AuditVerdict::Warn);
995        assert!(!output.summary.dead_code_has_errors);
996    }
997
998    #[test]
999    fn run_audit_styling_error_matches_cli_for_new_only_and_all_gates() {
1000        let project = audit_styling_fixture();
1001        let root = project.path();
1002        std::fs::write(
1003            root.join("src/styles.css"),
1004            "#app .legacy .title { color: red; }\n.plain { color: blue; }\n",
1005        )
1006        .expect("write inherited-only change");
1007
1008        let all = run_audit(&AuditOptions {
1009            analysis: AnalysisOptions {
1010                root: Some(root.to_path_buf()),
1011                no_cache: true,
1012                ..AnalysisOptions::default()
1013            },
1014            base: Some("HEAD".to_string()),
1015            gate: AuditGate::All,
1016            ..AuditOptions::default()
1017        })
1018        .expect("all-gate audit");
1019        assert_eq!(all.verdict, AuditVerdict::Fail);
1020
1021        let inherited_only = run_audit(&AuditOptions {
1022            analysis: AnalysisOptions {
1023                root: Some(root.to_path_buf()),
1024                no_cache: true,
1025                ..AnalysisOptions::default()
1026            },
1027            base: Some("HEAD".to_string()),
1028            gate: AuditGate::NewOnly,
1029            ..AuditOptions::default()
1030        })
1031        .expect("new-only inherited audit");
1032        assert_eq!(inherited_only.verdict, AuditVerdict::Pass);
1033        assert!(inherited_only.base_snapshot.is_some());
1034        let inherited_json =
1035            crate::serialize_audit_programmatic_json(inherited_only).expect("inherited audit JSON");
1036        assert_eq!(
1037            inherited_json["complexity"]["styling_findings"][0]["introduced"],
1038            false
1039        );
1040
1041        std::fs::write(
1042            root.join("src/styles.css"),
1043            "#app .legacy .title { color: red; }\n.plain { color: blue; }\n#app .introduced .title { color: green; }\n",
1044        )
1045        .expect("write introduced styling change");
1046        let introduced = run_audit(&AuditOptions {
1047            analysis: AnalysisOptions {
1048                root: Some(root.to_path_buf()),
1049                no_cache: true,
1050                ..AnalysisOptions::default()
1051            },
1052            base: Some("HEAD".to_string()),
1053            gate: AuditGate::NewOnly,
1054            ..AuditOptions::default()
1055        })
1056        .expect("new-only introduced audit");
1057        assert_eq!(introduced.verdict, AuditVerdict::Fail);
1058        let introduced_json =
1059            crate::serialize_audit_programmatic_json(introduced).expect("introduced audit JSON");
1060        let styling = introduced_json["complexity"]["styling_findings"]
1061            .as_array()
1062            .expect("styling findings");
1063        assert!(
1064            styling
1065                .iter()
1066                .any(|finding| finding["line"] == 1 && finding["introduced"] == false)
1067        );
1068        assert!(
1069            styling
1070                .iter()
1071                .any(|finding| finding["line"] == 3 && finding["introduced"] == true)
1072        );
1073    }
1074
1075    #[test]
1076    fn audit_production_mode_branches_preserve_per_section_workspace_scope() {
1077        let project = audit_workspace_modes_fixture();
1078
1079        for mask in 0_u8..8 {
1080            let production_dead_code = mask & 0b001 != 0;
1081            let production_health = mask & 0b010 != 0;
1082            let production_dupes = mask & 0b100 != 0;
1083            let output = run_audit(&AuditOptions {
1084                analysis: AnalysisOptions {
1085                    root: Some(project.path().to_path_buf()),
1086                    workspace: Some(vec!["@audit/a".to_string()]),
1087                    no_cache: true,
1088                    ..AnalysisOptions::default()
1089                },
1090                base: Some("HEAD".to_string()),
1091                gate: AuditGate::All,
1092                production_dead_code: Some(production_dead_code),
1093                production_health: Some(production_health),
1094                production_dupes: Some(production_dupes),
1095                include_entry_exports: true,
1096                ..AuditOptions::default()
1097            })
1098            .unwrap_or_else(|error| panic!("audit mask {mask:03b} failed: {error}"));
1099            let json = crate::serialize_audit_programmatic_json(output)
1100                .unwrap_or_else(|error| panic!("serialize mask {mask:03b}: {error}"));
1101
1102            let dead_code = json["dead_code"].to_string();
1103            let complexity = json["complexity"].to_string();
1104            let duplication = json["duplication"].to_string();
1105            assert_eq!(
1106                dead_code.contains("mode-sentinel.test.ts"),
1107                !production_dead_code,
1108                "dead-code scope mismatch for mask {mask:03b}: {dead_code}"
1109            );
1110            assert_eq!(
1111                complexity.contains("mode-sentinel.test.ts"),
1112                !production_health,
1113                "health scope mismatch for mask {mask:03b}: {complexity}"
1114            );
1115            assert_eq!(
1116                duplication.contains("mode-sentinel.test.ts"),
1117                !production_dupes,
1118                "duplication scope mismatch for mask {mask:03b}: {duplication}"
1119            );
1120
1121            let rendered = json.to_string();
1122            assert!(
1123                !rendered.contains("packages/b"),
1124                "workspace B leaked into mask {mask:03b}: {rendered}"
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn empty_audit_output_uses_resolved_root_for_head_sha() {
1131        let project = audit_fixture();
1132        let output = empty_audit_output(
1133            &AuditOptions {
1134                analysis: AnalysisOptions {
1135                    root: None,
1136                    ..AnalysisOptions::default()
1137                },
1138                base: Some("HEAD".to_string()),
1139                gate: AuditGate::NewOnly,
1140                ..AuditOptions::default()
1141            },
1142            ResolvedAuditBase {
1143                git_ref: "HEAD".to_string(),
1144                description: None,
1145            },
1146            project.path(),
1147            0,
1148            std::time::Duration::ZERO,
1149        );
1150
1151        assert!(output.head_sha.is_some());
1152    }
1153
1154    fn audit_fixture() -> tempfile::TempDir {
1155        let project = tempfile::tempdir().expect("project");
1156        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1157        std::fs::write(
1158            project.path().join("package.json"),
1159            r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
1160        )
1161        .expect("write package");
1162        std::fs::write(
1163            project.path().join("src/index.ts"),
1164            "console.log('entry');\n",
1165        )
1166        .expect("write entry");
1167        git(project.path(), &["init"]);
1168        git(project.path(), &["add", "."]);
1169        git(
1170            project.path(),
1171            &[
1172                "-c",
1173                "user.email=test@example.com",
1174                "-c",
1175                "user.name=Test",
1176                "-c",
1177                "commit.gpgsign=false",
1178                "commit",
1179                "-m",
1180                "initial",
1181            ],
1182        );
1183        std::fs::write(
1184            project.path().join("src/feature.ts"),
1185            "export const unused = 1;\n",
1186        )
1187        .expect("write changed source");
1188        project
1189    }
1190
1191    fn audit_styling_fixture() -> tempfile::TempDir {
1192        let project = tempfile::tempdir().expect("project");
1193        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1194        std::fs::write(
1195            project.path().join("package.json"),
1196            r#"{"name":"audit-api-styling","type":"module","main":"src/index.ts"}"#,
1197        )
1198        .expect("write package");
1199        std::fs::write(
1200            project.path().join(".fallowrc.json"),
1201            r#"{"rules":{"css-selector-complexity":"error"}}"#,
1202        )
1203        .expect("write config");
1204        std::fs::write(
1205            project.path().join("src/index.ts"),
1206            "console.log('entry');\n",
1207        )
1208        .expect("write entry");
1209        std::fs::write(
1210            project.path().join("src/styles.css"),
1211            "#app .legacy .title { color: red; }\n",
1212        )
1213        .expect("write inherited styling");
1214        git(project.path(), &["init"]);
1215        git(project.path(), &["add", "."]);
1216        git(
1217            project.path(),
1218            &[
1219                "-c",
1220                "user.email=test@example.com",
1221                "-c",
1222                "user.name=Test",
1223                "-c",
1224                "commit.gpgsign=false",
1225                "commit",
1226                "-m",
1227                "initial",
1228            ],
1229        );
1230        project
1231    }
1232
1233    fn audit_workspace_modes_fixture() -> tempfile::TempDir {
1234        let project = tempfile::tempdir().expect("project");
1235        std::fs::write(
1236            project.path().join("package.json"),
1237            r#"{"name":"audit-root","private":true,"workspaces":["packages/*"]}"#,
1238        )
1239        .expect("write root package");
1240        std::fs::write(
1241            project.path().join(".fallowrc.json"),
1242            r#"{
1243  "duplicates": {
1244    "minTokens": 10,
1245    "minLines": 2,
1246    "ignoreDefaults": false
1247  },
1248  "health": {
1249    "maxCyclomatic": 2,
1250    "maxCognitive": 2,
1251    "maxCrap": 2.0,
1252    "maxUnitSize": 3
1253  }
1254}"#,
1255        )
1256        .expect("write config");
1257
1258        for name in ["a", "b"] {
1259            let package = project.path().join("packages").join(name);
1260            std::fs::create_dir_all(package.join("src")).expect("create package source");
1261            std::fs::write(
1262                package.join("package.json"),
1263                format!(r#"{{"name":"@audit/{name}","type":"module","main":"src/index.ts"}}"#),
1264            )
1265            .expect("write package manifest");
1266            std::fs::write(
1267                package.join("src/index.ts"),
1268                format!("export const {name}Entry = true;\n"),
1269            )
1270            .expect("write package entry");
1271        }
1272
1273        git(project.path(), &["init"]);
1274        git(project.path(), &["add", "."]);
1275        git(
1276            project.path(),
1277            &[
1278                "-c",
1279                "user.email=test@example.com",
1280                "-c",
1281                "user.name=Test",
1282                "-c",
1283                "commit.gpgsign=false",
1284                "commit",
1285                "-m",
1286                "initial",
1287            ],
1288        );
1289
1290        let sentinel = r"export function auditModeSentinel(value: number) {
1291  let result = value;
1292  if (value > 0) result += 1;
1293  if (value > 1) result += 2;
1294  if (value > 2) result += 3;
1295  if (value > 3) result += 4;
1296  return result;
1297}
1298";
1299        for name in ["a", "b"] {
1300            let source = project.path().join("packages").join(name).join("src");
1301            std::fs::write(source.join("mode-sentinel.test.ts"), sentinel)
1302                .expect("write test sentinel");
1303            std::fs::write(source.join("mode-sentinel-copy.test.ts"), sentinel)
1304                .expect("write duplicate test sentinel");
1305        }
1306
1307        project
1308    }
1309
1310    fn git(root: &Path, args: &[&str]) {
1311        let status = Command::new("git")
1312            .args(args)
1313            .current_dir(root)
1314            .status()
1315            .expect("git command");
1316        assert!(status.success(), "git {args:?} failed");
1317    }
1318}