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