Skip to main content

fallow_api/runtime/
audit.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3use std::time::{Instant, SystemTime};
4
5use fallow_config::AuditGate;
6use fallow_engine::{
7    changed_files::clear_ambient_git_env, dead_code::DeadCodeAnalysisArtifacts,
8    project_analysis::ProjectAnalysisArtifactOptions, session::AnalysisSession,
9};
10use fallow_output::build_audit_next_steps;
11use fallow_types::output::NextStep;
12use rustc_hash::FxHashSet;
13
14use crate::{
15    AnalysisOptions, AuditAttribution, AuditOptions, AuditProgrammaticKeySnapshot,
16    AuditProgrammaticOutput, AuditSummary, AuditVerdict, ComplexityOptions, DeadCodeFilters,
17    DeadCodeOptions, DuplicationOptions, ProgrammaticError,
18    analysis_context::{
19        ProgrammaticAnalysisContext, changed_files_for_run, resolve_programmatic_analysis_context,
20        resolve_programmatic_analysis_context_deferred_workspace,
21    },
22};
23
24use super::{
25    ProgrammaticResult, health_may_consume_dead_code_artifacts,
26    health_may_consume_duplication_report, resolve_effective_production_modes, root_envelope_mode,
27    run_dead_code, run_duplication, run_health, run_health_with_session_artifacts,
28};
29
30/// Run changed-code audit through typed programmatic runners.
31///
32/// # Errors
33///
34/// Returns a structured error for invalid options, base-ref discovery failures,
35/// unsupported CLI-only audit surfaces, or analysis failures.
36pub fn run_audit(options: &AuditOptions) -> ProgrammaticResult<AuditProgrammaticOutput> {
37    validate_audit_api_options(options)?;
38    let start = Instant::now();
39    let resolved_base = resolve_audit_base_ref(options)?;
40    let analysis = analysis_options_for_audit(options, &resolved_base.git_ref);
41    let resolved = resolve_programmatic_analysis_context(&analysis)?;
42    let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
43    let changed_files_count = changed_files.len();
44
45    if changed_files.is_empty() {
46        return Ok(empty_audit_output(
47            options,
48            resolved_base,
49            resolved.root(),
50            changed_files_count,
51            start.elapsed(),
52        ));
53    }
54
55    let head = run_audit_subanalyses(options, &analysis, Some(&changed_files))?;
56    let current_snapshot = snapshot_from_analyses(&head);
57    let base_snapshot = if matches!(options.gate, AuditGate::NewOnly) {
58        Some(compute_base_snapshot(options, &resolved_base.git_ref)?)
59    } else {
60        None
61    };
62    let summary = build_programmatic_audit_summary(&head);
63    let attribution = compute_programmatic_audit_attribution(
64        options.gate,
65        &current_snapshot,
66        base_snapshot.as_ref(),
67    );
68    let verdict = compute_programmatic_audit_verdict(
69        options.gate,
70        &summary,
71        &head.duplication,
72        &current_snapshot,
73        base_snapshot.as_ref(),
74    );
75    let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
76
77    Ok(AuditProgrammaticOutput {
78        verdict,
79        summary,
80        attribution,
81        changed_files_count,
82        base_ref: resolved_base.git_ref,
83        base_description: resolved_base.description,
84        head_sha: get_head_sha(resolved.root()),
85        elapsed: start.elapsed(),
86        base_snapshot_skipped: None,
87        base_snapshot,
88        dead_code: Some(head.dead_code),
89        duplication: Some(head.duplication),
90        complexity: Some(head.complexity),
91        next_steps,
92        envelope_mode: root_envelope_mode(),
93        telemetry_analysis_run_id: None,
94    })
95}
96
97fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
98    if let Err(err) =
99        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
100    {
101        return Err(ProgrammaticError::new(err, 2)
102            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
103            .with_context("audit.coverageRoot"));
104    }
105    if options.runtime_coverage.is_some() {
106        return Err(ProgrammaticError::new(
107            "programmatic audit does not yet support runtime coverage; use the CLI path",
108            2,
109        )
110        .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
111        .with_context("audit.runtimeCoverage"));
112    }
113    Ok(())
114}
115
116#[derive(Debug, Clone)]
117pub(super) struct ResolvedAuditBase {
118    pub(super) git_ref: String,
119    pub(super) description: Option<String>,
120}
121
122pub(super) fn resolve_audit_base_ref(
123    options: &AuditOptions,
124) -> ProgrammaticResult<ResolvedAuditBase> {
125    if let Some(ref_str) = options
126        .base
127        .as_deref()
128        .or(options.analysis.changed_since.as_deref())
129    {
130        validate_git_ref(ref_str, "audit.base")?;
131        return Ok(ResolvedAuditBase {
132            git_ref: (*ref_str).to_string(),
133            description: None,
134        });
135    }
136    if let Some(env_ref) = audit_base_env_override() {
137        validate_git_ref(&env_ref, "FALLOW_AUDIT_BASE")?;
138        return Ok(ResolvedAuditBase {
139            description: Some(format!("FALLOW_AUDIT_BASE={env_ref}")),
140            git_ref: env_ref,
141        });
142    }
143    let root = options
144        .analysis
145        .root
146        .clone()
147        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
148    auto_detect_base_ref(&root).ok_or_else(|| {
149        ProgrammaticError::new(
150            "could not detect base branch. Set audit.base to specify the comparison target",
151            2,
152        )
153        .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
154        .with_context("audit.base")
155    })
156}
157
158fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
159    AnalysisOptions {
160        changed_since: Some(base_ref.to_string()),
161        production: options.production,
162        production_override: options.production.then_some(true),
163        ..options.analysis.clone()
164    }
165}
166
167fn analysis_with_production(
168    analysis: &AnalysisOptions,
169    production_override: Option<bool>,
170) -> AnalysisOptions {
171    AnalysisOptions {
172        production: production_override.unwrap_or(analysis.production),
173        production_override: production_override.or(analysis.production_override),
174        ..analysis.clone()
175    }
176}
177
178fn empty_audit_output(
179    options: &AuditOptions,
180    base: ResolvedAuditBase,
181    root: &Path,
182    changed_files_count: usize,
183    elapsed: std::time::Duration,
184) -> AuditProgrammaticOutput {
185    AuditProgrammaticOutput {
186        verdict: AuditVerdict::Pass,
187        summary: AuditSummary {
188            dead_code_issues: 0,
189            dead_code_has_errors: false,
190            complexity_findings: 0,
191            max_cyclomatic: None,
192            duplication_clone_groups: 0,
193        },
194        attribution: AuditAttribution {
195            gate: options.gate,
196            ..AuditAttribution::default()
197        },
198        changed_files_count,
199        base_ref: base.git_ref,
200        base_description: base.description,
201        head_sha: get_head_sha(root),
202        elapsed,
203        base_snapshot_skipped: None,
204        base_snapshot: None,
205        dead_code: None,
206        duplication: None,
207        complexity: None,
208        next_steps: Vec::new(),
209        envelope_mode: root_envelope_mode(),
210        telemetry_analysis_run_id: None,
211    }
212}
213
214struct AuditSubanalyses {
215    dead_code: crate::DeadCodeProgrammaticOutput,
216    duplication: crate::DuplicationProgrammaticOutput,
217    complexity: crate::HealthProgrammaticOutput,
218}
219
220fn run_audit_subanalyses(
221    options: &AuditOptions,
222    analysis: &AnalysisOptions,
223    changed_files: Option<&FxHashSet<PathBuf>>,
224) -> ProgrammaticResult<AuditSubanalyses> {
225    let dead_code_options = DeadCodeOptions {
226        analysis: analysis_with_production(analysis, options.production_dead_code),
227        filters: DeadCodeFilters::default(),
228        files: Vec::new(),
229        include_entry_exports: options.include_entry_exports,
230    };
231    let duplication_options = DuplicationOptions {
232        analysis: analysis_with_production(analysis, options.production_dupes),
233        ..DuplicationOptions::default()
234    };
235    let complexity_options = ComplexityOptions {
236        analysis: analysis_with_production(analysis, options.production_health),
237        max_crap: options.max_crap,
238        complexity: true,
239        css: options.css.unwrap_or(true),
240        css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
241        coverage: options.coverage.clone(),
242        coverage_root: options.coverage_root.clone(),
243        ..ComplexityOptions::default()
244    };
245    let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
246    let production_modes = resolve_effective_production_modes(
247        &resolved,
248        options.production_dead_code,
249        options.production_health,
250        options.production_dupes,
251    )?;
252
253    if production_modes.dead_code == production_modes.dupes
254        && production_modes.dead_code == production_modes.health
255    {
256        return resolved.install(|| {
257            let session = super::dead_code::load_dead_code_session(&dead_code_options, &resolved)?;
258            run_all_audit_subanalyses_with_project_artifacts(
259                &dead_code_options,
260                &duplication_options,
261                &complexity_options,
262                &resolved,
263                &session,
264                changed_files,
265            )
266        });
267    }
268
269    if production_modes.dead_code == production_modes.health {
270        return resolved.install(|| {
271            let session = super::dead_code::load_dead_code_session(&dead_code_options, &resolved)?;
272            let (dead_code, complexity) = run_dead_code_and_health_with_session(
273                &dead_code_options,
274                &complexity_options,
275                &resolved,
276                &session,
277                changed_files,
278            )?;
279            Ok(AuditSubanalyses {
280                dead_code,
281                duplication: run_duplication(&duplication_options)?,
282                complexity,
283            })
284        });
285    }
286
287    if production_modes.dead_code == production_modes.dupes {
288        return resolved.install(|| {
289            let session = super::dead_code::load_dead_code_session(&dead_code_options, &resolved)?;
290            let (dead_code, duplication, _, _) =
291                run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
292                    dead_code_options: &dead_code_options,
293                    duplication_options: &duplication_options,
294                    resolved: &resolved,
295                    session: &session,
296                    changed_files,
297                    retain_dead_code_artifacts: false,
298                    retain_duplication_artifacts: false,
299                })?;
300            Ok(AuditSubanalyses {
301                dead_code,
302                duplication,
303                complexity: run_health(&complexity_options)?,
304            })
305        });
306    }
307
308    Ok(AuditSubanalyses {
309        dead_code: run_dead_code(&dead_code_options)?,
310        duplication: run_duplication(&duplication_options)?,
311        complexity: run_health(&complexity_options)?,
312    })
313}
314
315fn run_dead_code_and_duplication_with_project_artifacts(
316    input: ProjectArtifactAuditInput<'_>,
317) -> ProgrammaticResult<(
318    crate::DeadCodeProgrammaticOutput,
319    crate::DuplicationProgrammaticOutput,
320    Option<DeadCodeAnalysisArtifacts>,
321    Option<fallow_engine::duplicates::DuplicationReport>,
322)> {
323    let dupes_config = super::duplication::build_dupes_config(
324        input.duplication_options,
325        &input.session.config().duplicates,
326    );
327    let section_start = Instant::now();
328    let project = input
329        .session
330        .analyze_project_with_artifacts(
331            &dupes_config,
332            ProjectAnalysisArtifactOptions {
333                retain_complexity_artifacts: input.retain_dead_code_artifacts,
334                retain_graph: input.retain_dead_code_artifacts,
335                changed_files: input.changed_files.cloned(),
336                collect_source_fingerprints: false,
337            },
338        )
339        .map_err(|err| {
340            ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
341                .with_code("FALLOW_AUDIT_FAILED")
342                .with_context("audit")
343        })?;
344    let duplication_artifacts = input
345        .retain_duplication_artifacts
346        .then(|| project.duplication.clone());
347    let dead_code = super::dead_code::run_dead_code_from_artifacts(
348        input.dead_code_options,
349        input.resolved,
350        input.session,
351        input.changed_files,
352        project.dead_code,
353        section_start,
354    )?;
355    let duplication = super::duplication::run_duplication_report_with_session(
356        input.duplication_options,
357        input.resolved,
358        input.session,
359        project.duplication,
360        section_start,
361    )?;
362    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
363        output: dead_code,
364        artifacts,
365    } = dead_code;
366    let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
367    Ok((
368        dead_code,
369        duplication,
370        dead_code_artifacts,
371        duplication_artifacts,
372    ))
373}
374
375#[derive(Clone, Copy)]
376struct ProjectArtifactAuditInput<'a> {
377    dead_code_options: &'a DeadCodeOptions,
378    duplication_options: &'a DuplicationOptions,
379    resolved: &'a ProgrammaticAnalysisContext,
380    session: &'a AnalysisSession,
381    changed_files: Option<&'a FxHashSet<PathBuf>>,
382    retain_dead_code_artifacts: bool,
383    retain_duplication_artifacts: bool,
384}
385
386fn run_all_audit_subanalyses_with_project_artifacts(
387    dead_code_options: &DeadCodeOptions,
388    duplication_options: &DuplicationOptions,
389    complexity_options: &ComplexityOptions,
390    resolved: &ProgrammaticAnalysisContext,
391    session: &AnalysisSession,
392    changed_files: Option<&FxHashSet<PathBuf>>,
393) -> ProgrammaticResult<AuditSubanalyses> {
394    let retain_dead_code_artifacts =
395        health_may_consume_dead_code_artifacts(complexity_options, session.config());
396    let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
397    let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
398        run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
399            dead_code_options,
400            duplication_options,
401            resolved,
402            session,
403            changed_files,
404            retain_dead_code_artifacts,
405            retain_duplication_artifacts,
406        })?;
407    let complexity = run_health_with_session_artifacts(
408        complexity_options,
409        resolved,
410        session,
411        changed_files,
412        dead_code_artifacts,
413        duplication_artifacts,
414    )?;
415    Ok(AuditSubanalyses {
416        dead_code,
417        duplication,
418        complexity,
419    })
420}
421
422fn run_dead_code_and_health_with_session(
423    dead_code_options: &DeadCodeOptions,
424    complexity_options: &ComplexityOptions,
425    resolved: &ProgrammaticAnalysisContext,
426    session: &AnalysisSession,
427    changed_files: Option<&FxHashSet<PathBuf>>,
428) -> ProgrammaticResult<(
429    crate::DeadCodeProgrammaticOutput,
430    crate::HealthProgrammaticOutput,
431)> {
432    let reuse_dead_code_artifacts =
433        health_may_consume_dead_code_artifacts(complexity_options, session.config());
434    let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
435        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
436            dead_code_options,
437            resolved,
438            session,
439            changed_files,
440            |_| {},
441            Instant::now(),
442        )?;
443        (dead_code.output, Some(dead_code.artifacts))
444    } else {
445        (
446            super::dead_code::run_dead_code_with_session(
447                dead_code_options,
448                resolved,
449                session,
450                changed_files,
451                |_| {},
452                Instant::now(),
453            )?,
454            None,
455        )
456    };
457    let complexity = run_health_with_session_artifacts(
458        complexity_options,
459        resolved,
460        session,
461        changed_files,
462        dead_code_artifacts,
463        None,
464    )?;
465    Ok((dead_code, complexity))
466}
467
468fn build_programmatic_audit_summary(analyses: &AuditSubanalyses) -> AuditSummary {
469    let dead_code_issues = analyses.dead_code.output.results.total_issues();
470    AuditSummary {
471        dead_code_issues,
472        dead_code_has_errors: dead_code_issues > 0,
473        complexity_findings: analyses.complexity.report.findings.len(),
474        max_cyclomatic: analyses
475            .complexity
476            .report
477            .findings
478            .iter()
479            .map(|finding| finding.cyclomatic)
480            .max(),
481        duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
482    }
483}
484
485fn compute_programmatic_audit_verdict(
486    gate: AuditGate,
487    summary: &AuditSummary,
488    duplication: &crate::DuplicationProgrammaticOutput,
489    current: &AuditProgrammaticKeySnapshot,
490    base: Option<&AuditProgrammaticKeySnapshot>,
491) -> AuditVerdict {
492    if matches!(gate, AuditGate::NewOnly) {
493        return compute_programmatic_introduced_verdict(summary, duplication, current, base);
494    }
495    if summary.dead_code_has_errors || summary.complexity_findings > 0 {
496        return AuditVerdict::Fail;
497    }
498    if summary.duplication_clone_groups > 0 {
499        let pct = duplication.output.report.stats.duplication_percentage;
500        if duplication.threshold > 0.0 && pct > duplication.threshold {
501            return AuditVerdict::Fail;
502        }
503        return AuditVerdict::Warn;
504    }
505    AuditVerdict::Pass
506}
507
508fn compute_programmatic_introduced_verdict(
509    summary: &AuditSummary,
510    duplication: &crate::DuplicationProgrammaticOutput,
511    current: &AuditProgrammaticKeySnapshot,
512    base: Option<&AuditProgrammaticKeySnapshot>,
513) -> AuditVerdict {
514    let attribution = compute_programmatic_audit_attribution(AuditGate::NewOnly, current, base);
515    if attribution.dead_code_introduced > 0 || attribution.complexity_introduced > 0 {
516        return AuditVerdict::Fail;
517    }
518    if attribution.duplication_introduced > 0 {
519        let pct = duplication.output.report.stats.duplication_percentage;
520        if duplication.threshold > 0.0 && pct > duplication.threshold {
521            return AuditVerdict::Fail;
522        }
523        return AuditVerdict::Warn;
524    }
525    if summary.dead_code_issues == 0
526        && summary.complexity_findings == 0
527        && summary.duplication_clone_groups == 0
528    {
529        return AuditVerdict::Pass;
530    }
531    AuditVerdict::Pass
532}
533
534fn compute_programmatic_audit_attribution(
535    gate: AuditGate,
536    current: &AuditProgrammaticKeySnapshot,
537    base: Option<&AuditProgrammaticKeySnapshot>,
538) -> AuditAttribution {
539    let dead_code = count_introduced(&current.dead_code, base.map(|snapshot| &snapshot.dead_code));
540    let complexity = count_introduced(&current.health, base.map(|snapshot| &snapshot.health));
541    let duplication = count_introduced(&current.dupes, base.map(|snapshot| &snapshot.dupes));
542    AuditAttribution {
543        gate,
544        dead_code_introduced: dead_code.0,
545        dead_code_inherited: dead_code.1,
546        complexity_introduced: complexity.0,
547        complexity_inherited: complexity.1,
548        duplication_introduced: duplication.0,
549        duplication_inherited: duplication.1,
550    }
551}
552
553fn count_introduced(
554    keys: &rustc_hash::FxHashSet<String>,
555    base: Option<&rustc_hash::FxHashSet<String>>,
556) -> (usize, usize) {
557    let Some(base) = base else {
558        return (0, 0);
559    };
560    keys.iter().fold((0, 0), |(introduced, inherited), key| {
561        if base.contains(key) {
562            (introduced, inherited + 1)
563        } else {
564            (introduced + 1, inherited)
565        }
566    })
567}
568
569fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditProgrammaticKeySnapshot {
570    AuditProgrammaticKeySnapshot {
571        dead_code: crate::audit_keys::dead_code_keys(
572            &analyses.dead_code.output.results,
573            &analyses.dead_code.root,
574        ),
575        health: crate::audit_keys::health_keys(
576            &analyses.complexity.report,
577            &analyses.complexity.root,
578        ),
579        dupes: analyses
580            .duplication
581            .output
582            .report
583            .clone_groups
584            .iter()
585            .map(|group| {
586                crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
587            })
588            .collect(),
589    }
590}
591
592fn compute_base_snapshot(
593    options: &AuditOptions,
594    base_ref: &str,
595) -> ProgrammaticResult<AuditProgrammaticKeySnapshot> {
596    let current_root = analysis_root_from_options(options)?;
597    let worktree = BaseWorktree::create(&current_root, base_ref)?;
598    let base_root = base_analysis_root(&current_root, worktree.path());
599    let current_config_path = options
600        .analysis
601        .config_path
602        .clone()
603        .or_else(|| fallow_config::FallowConfig::find_config_path(&current_root));
604    let base_analysis = AnalysisOptions {
605        root: Some(base_root),
606        config_path: current_config_path,
607        changed_since: None,
608        explain: false,
609        ..options.analysis.clone()
610    };
611    let base = run_audit_subanalyses(options, &base_analysis, None)?;
612    Ok(snapshot_from_analyses(&base))
613}
614
615fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
616    match options.analysis.root.clone() {
617        Some(root) => Ok(root),
618        None => std::env::current_dir().map_err(|err| {
619            ProgrammaticError::new(
620                format!("failed to resolve current working directory: {err}"),
621                2,
622            )
623            .with_code("FALLOW_CWD_UNAVAILABLE")
624            .with_context("analysis.root")
625        }),
626    }
627}
628
629pub(super) struct BaseWorktree {
630    repo_root: PathBuf,
631    path: PathBuf,
632}
633
634impl BaseWorktree {
635    pub(super) fn create(repo_root: &Path, base_ref: &str) -> ProgrammaticResult<Self> {
636        let path = base_worktree_path()?;
637        let mut command = Command::new("git");
638        command
639            .args([
640                "worktree",
641                "add",
642                "--detach",
643                "--quiet",
644                path.to_string_lossy().as_ref(),
645                base_ref,
646            ])
647            .current_dir(repo_root);
648        clear_ambient_git_env(&mut command);
649        let output = command.output().map_err(|err| {
650            ProgrammaticError::new(
651                format!("could not create a temporary worktree for base ref `{base_ref}`: {err}"),
652                2,
653            )
654            .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
655            .with_context("audit.base")
656        })?;
657        if !output.status.success() {
658            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
659            return Err(ProgrammaticError::new(
660                format!(
661                    "could not create a temporary worktree for base ref `{base_ref}`: {stderr}"
662                ),
663                2,
664            )
665            .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
666            .with_context("audit.base"));
667        }
668        Ok(Self {
669            repo_root: repo_root.to_path_buf(),
670            path,
671        })
672    }
673
674    pub(super) fn path(&self) -> &Path {
675        &self.path
676    }
677}
678
679impl Drop for BaseWorktree {
680    fn drop(&mut self) {
681        let mut command = Command::new("git");
682        command
683            .args([
684                "worktree",
685                "remove",
686                "--force",
687                self.path.to_string_lossy().as_ref(),
688            ])
689            .current_dir(&self.repo_root);
690        clear_ambient_git_env(&mut command);
691        let _ = command.output();
692        let _ = std::fs::remove_dir_all(&self.path);
693    }
694}
695
696fn base_worktree_path() -> ProgrammaticResult<PathBuf> {
697    let nanos = SystemTime::now()
698        .duration_since(SystemTime::UNIX_EPOCH)
699        .map_err(|err| {
700            ProgrammaticError::new(format!("system clock before unix epoch: {err}"), 2)
701                .with_code("FALLOW_CLOCK_ERROR")
702                .with_context("audit.base")
703        })?
704        .as_nanos();
705    Ok(std::env::temp_dir().join(format!(
706        "fallow-api-audit-base-{}-{nanos}",
707        std::process::id()
708    )))
709}
710
711pub(super) fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
712    let Some(git_root) = git_toplevel(current_root) else {
713        return base_worktree_root.to_path_buf();
714    };
715    let current_root =
716        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
717    match current_root.strip_prefix(&git_root) {
718        Ok(relative) => base_worktree_root.join(relative),
719        Err(_) => base_worktree_root.to_path_buf(),
720    }
721}
722
723fn git_toplevel(root: &Path) -> Option<PathBuf> {
724    git_stdout(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
725}
726
727fn audit_next_steps(
728    dead_code: &crate::DeadCodeProgrammaticOutput,
729    complexity: &crate::HealthProgrammaticOutput,
730) -> Vec<NextStep> {
731    let input = fallow_output::build_audit_next_steps_input(
732        Some((&dead_code.output.results, dead_code.root.as_path())),
733        Some(&complexity.report),
734        crate::next_steps::suggestions_enabled(),
735    );
736    build_audit_next_steps(&input)
737}
738
739fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
740    fallow_engine::validate::validate_git_ref(value)
741        .map(|_| ())
742        .map_err(|err| {
743            ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
744                .with_code("FALLOW_INVALID_GIT_REF")
745                .with_context(context)
746        })
747}
748
749fn audit_base_env_override() -> Option<String> {
750    std::env::var("FALLOW_AUDIT_BASE")
751        .ok()
752        .map(|value| value.trim().to_string())
753        .filter(|value| !value.is_empty())
754}
755
756fn auto_detect_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
757    if let Some(upstream) = git_upstream_ref(root) {
758        if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
759            return Some(ResolvedAuditBase {
760                git_ref: sha,
761                description: Some(format!("merge-base with {upstream}")),
762            });
763        }
764        return Some(ResolvedAuditBase {
765            description: Some(format!("{upstream} (tip)")),
766            git_ref: upstream,
767        });
768    }
769
770    if let Some(remote_ref) = detect_remote_default_ref(root) {
771        if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
772            return Some(ResolvedAuditBase {
773                git_ref: sha,
774                description: Some(format!("merge-base with {remote_ref}")),
775            });
776        }
777        return Some(ResolvedAuditBase {
778            description: Some(format!("{remote_ref} (tip)")),
779            git_ref: remote_ref,
780        });
781    }
782
783    for candidate in ["main", "master"] {
784        if git_ref_exists(root, candidate) {
785            return Some(ResolvedAuditBase {
786                git_ref: candidate.to_string(),
787                description: Some(format!("local {candidate}")),
788            });
789        }
790    }
791
792    None
793}
794
795fn git_stdout(root: &Path, args: &[&str]) -> Option<String> {
796    let mut command = Command::new("git");
797    command.args(args).current_dir(root);
798    clear_ambient_git_env(&mut command);
799    let output = command.output().ok()?;
800    if !output.status.success() {
801        return None;
802    }
803    let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
804    (!trimmed.is_empty()).then_some(trimmed)
805}
806
807fn git_ref_exists(root: &Path, git_ref: &str) -> bool {
808    git_stdout(root, &["rev-parse", "--verify", "--quiet", git_ref]).is_some()
809}
810
811fn git_upstream_ref(root: &Path) -> Option<String> {
812    git_stdout(
813        root,
814        &[
815            "rev-parse",
816            "--abbrev-ref",
817            "--symbolic-full-name",
818            "@{upstream}",
819        ],
820    )
821}
822
823fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
824    git_stdout(root, &["merge-base", a, b])
825}
826
827fn detect_remote_default_ref(root: &Path) -> Option<String> {
828    if let Some(full_ref) = git_stdout(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
829        && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
830    {
831        return Some(format!("origin/{branch}"));
832    }
833    ["origin/main", "origin/master"]
834        .into_iter()
835        .find(|candidate| git_ref_exists(root, candidate))
836        .map(str::to_string)
837}
838
839fn get_head_sha(root: &Path) -> Option<String> {
840    git_stdout(root, &["rev-parse", "--short", "HEAD"])
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!(
966            json["dead_code"]["unused_files"][0]["path"],
967            "src/feature.ts"
968        );
969        assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
970    }
971
972    #[test]
973    fn empty_audit_output_uses_resolved_root_for_head_sha() {
974        let project = audit_fixture();
975        let output = empty_audit_output(
976            &AuditOptions {
977                analysis: AnalysisOptions {
978                    root: None,
979                    ..AnalysisOptions::default()
980                },
981                base: Some("HEAD".to_string()),
982                gate: AuditGate::NewOnly,
983                ..AuditOptions::default()
984            },
985            ResolvedAuditBase {
986                git_ref: "HEAD".to_string(),
987                description: None,
988            },
989            project.path(),
990            0,
991            std::time::Duration::ZERO,
992        );
993
994        assert!(output.head_sha.is_some());
995    }
996
997    fn audit_fixture() -> tempfile::TempDir {
998        let project = tempfile::tempdir().expect("project");
999        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1000        std::fs::write(
1001            project.path().join("package.json"),
1002            r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
1003        )
1004        .expect("write package");
1005        std::fs::write(
1006            project.path().join("src/index.ts"),
1007            "console.log('entry');\n",
1008        )
1009        .expect("write entry");
1010        git(project.path(), &["init"]);
1011        git(project.path(), &["add", "."]);
1012        git(
1013            project.path(),
1014            &[
1015                "-c",
1016                "user.email=test@example.com",
1017                "-c",
1018                "user.name=Test",
1019                "-c",
1020                "commit.gpgsign=false",
1021                "commit",
1022                "-m",
1023                "initial",
1024            ],
1025        );
1026        std::fs::write(
1027            project.path().join("src/feature.ts"),
1028            "export const unused = 1;\n",
1029        )
1030        .expect("write changed source");
1031        project
1032    }
1033
1034    fn git(root: &Path, args: &[&str]) {
1035        let status = Command::new("git")
1036            .args(args)
1037            .current_dir(root)
1038            .status()
1039            .expect("git command");
1040        assert!(status.success(), "git {args:?} failed");
1041    }
1042}