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    let detected = 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    validate_git_ref(&detected.git_ref, "audit.base")?;
174    Ok(detected)
175}
176
177fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
178    let production_override = options
179        .analysis
180        .production_override
181        .or_else(|| options.production.then_some(true));
182    AnalysisOptions {
183        changed_since: Some(base_ref.to_string()),
184        production: production_override.unwrap_or(options.production),
185        production_override,
186        ..options.analysis.clone()
187    }
188}
189
190fn analysis_with_production(
191    analysis: &AnalysisOptions,
192    production_override: Option<bool>,
193) -> AnalysisOptions {
194    AnalysisOptions {
195        production: production_override.unwrap_or(analysis.production),
196        production_override: production_override.or(analysis.production_override),
197        ..analysis.clone()
198    }
199}
200
201fn empty_audit_output(
202    options: &AuditOptions,
203    base: ResolvedAuditBase,
204    root: &Path,
205    changed_files_count: usize,
206    elapsed: std::time::Duration,
207) -> AuditProgrammaticOutput {
208    AuditProgrammaticOutput {
209        verdict: AuditVerdict::Pass,
210        summary: AuditSummary {
211            dead_code_issues: 0,
212            dead_code_has_errors: false,
213            complexity_findings: 0,
214            max_cyclomatic: None,
215            duplication_clone_groups: 0,
216        },
217        attribution: AuditAttribution {
218            gate: options.gate,
219            ..AuditAttribution::default()
220        },
221        changed_files_count,
222        base_ref: base.git_ref,
223        base_description: base.description,
224        head_sha: repo_refs::short_head_sha(root),
225        elapsed,
226        base_snapshot_skipped: None,
227        base_snapshot: None,
228        dead_code: None,
229        duplication: None,
230        complexity: None,
231        next_steps: Vec::new(),
232        envelope_mode: root_envelope_mode(),
233        telemetry_analysis_run_id: None,
234    }
235}
236
237struct AuditSubanalyses {
238    dead_code: crate::DeadCodeProgrammaticOutput,
239    duplication: crate::DuplicationProgrammaticOutput,
240    complexity: crate::HealthProgrammaticOutput,
241}
242
243#[derive(Default)]
244struct AuditRuntimeKeySnapshot {
245    public: AuditProgrammaticKeySnapshot,
246    styling: FxHashSet<String>,
247}
248
249struct AuditSubanalysisOptions {
250    dead_code: DeadCodeOptions,
251    duplication: DuplicationOptions,
252    complexity: ComplexityOptions,
253}
254
255fn audit_subanalysis_options(
256    options: &AuditOptions,
257    analysis: &AnalysisOptions,
258    coverage_relocated: bool,
259) -> AuditSubanalysisOptions {
260    AuditSubanalysisOptions {
261        dead_code: DeadCodeOptions {
262            analysis: analysis_with_production(analysis, options.production_dead_code),
263            filters: DeadCodeFilters::default(),
264            files: Vec::new(),
265            include_entry_exports: options.include_entry_exports,
266        },
267        duplication: DuplicationOptions {
268            analysis: analysis_with_production(analysis, options.production_dupes),
269            ..DuplicationOptions::default()
270        },
271        complexity: ComplexityOptions {
272            analysis: analysis_with_production(analysis, options.production_health),
273            max_crap: options.max_crap,
274            complexity: true,
275            css: options.css.unwrap_or(true),
276            css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
277            coverage: options.coverage.clone(),
278            coverage_root: options.coverage_root.clone(),
279            coverage_relocated,
280            ..ComplexityOptions::default()
281        },
282    }
283}
284
285fn run_audit_subanalyses(
286    options: &AuditOptions,
287    analysis: &AnalysisOptions,
288    changed_files: Option<&FxHashSet<PathBuf>>,
289    coverage_relocated: bool,
290) -> ProgrammaticResult<AuditSubanalyses> {
291    let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
292    run_audit_subanalyses_in_context(
293        options,
294        analysis,
295        &resolved,
296        changed_files,
297        coverage_relocated,
298    )
299}
300
301fn run_audit_subanalyses_with_context(
302    options: &AuditOptions,
303    analysis: &AnalysisOptions,
304    resolved: &ProgrammaticAnalysisContext,
305    changed_files: Option<&FxHashSet<PathBuf>>,
306) -> ProgrammaticResult<AuditSubanalyses> {
307    run_audit_subanalyses_in_context(options, analysis, resolved, changed_files, false)
308}
309
310fn run_audit_subanalyses_in_context(
311    options: &AuditOptions,
312    analysis: &AnalysisOptions,
313    resolved: &ProgrammaticAnalysisContext,
314    changed_files: Option<&FxHashSet<PathBuf>>,
315    coverage_relocated: bool,
316) -> ProgrammaticResult<AuditSubanalyses> {
317    let subanalysis_options = audit_subanalysis_options(options, analysis, coverage_relocated);
318    let production_modes = resolve_effective_production_modes(
319        resolved,
320        options.production_dead_code,
321        options.production_health,
322        options.production_dupes,
323    )?;
324
325    if production_modes.dead_code == production_modes.dupes
326        && production_modes.dead_code == production_modes.health
327    {
328        return run_shared_project_audit_subanalyses(&subanalysis_options, changed_files);
329    }
330
331    if production_modes.dead_code == production_modes.health {
332        return run_shared_dead_code_health_audit_subanalyses(&subanalysis_options, changed_files);
333    }
334
335    if production_modes.dead_code == production_modes.dupes {
336        return run_shared_dead_code_dupes_audit_subanalyses(&subanalysis_options, changed_files);
337    }
338
339    Ok(AuditSubanalyses {
340        dead_code: run_dead_code(&subanalysis_options.dead_code)?,
341        duplication: run_duplication(&subanalysis_options.duplication)?,
342        complexity: run_health(&subanalysis_options.complexity)?,
343    })
344}
345
346fn run_shared_project_audit_subanalyses(
347    options: &AuditSubanalysisOptions,
348    changed_files: Option<&FxHashSet<PathBuf>>,
349) -> ProgrammaticResult<AuditSubanalyses> {
350    let resolved =
351        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
352    resolved.install(|| {
353        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
354        run_all_audit_subanalyses_with_project_artifacts(
355            &options.dead_code,
356            &options.duplication,
357            &options.complexity,
358            &resolved,
359            &session,
360            changed_files,
361        )
362    })
363}
364
365fn run_shared_dead_code_health_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 dead_code_options = &options.dead_code;
373        let duplication_options = &options.duplication;
374        let complexity_options = &options.complexity;
375        let session = super::dead_code::load_dead_code_session(dead_code_options, &resolved)?;
376        let (dead_code, complexity) = run_dead_code_and_health_with_session(
377            dead_code_options,
378            complexity_options,
379            &resolved,
380            &session,
381            changed_files,
382        )?;
383        Ok(AuditSubanalyses {
384            dead_code,
385            duplication: run_duplication(duplication_options)?,
386            complexity,
387        })
388    })
389}
390
391fn run_shared_dead_code_dupes_audit_subanalyses(
392    options: &AuditSubanalysisOptions,
393    changed_files: Option<&FxHashSet<PathBuf>>,
394) -> ProgrammaticResult<AuditSubanalyses> {
395    let resolved =
396        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
397    resolved.install(|| {
398        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
399        let (dead_code, duplication, _, _) =
400            run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
401                dead_code_options: &options.dead_code,
402                duplication_options: &options.duplication,
403                resolved: &resolved,
404                session: &session,
405                changed_files,
406                retain_dead_code_artifacts: false,
407                retain_duplication_artifacts: false,
408            })?;
409        Ok(AuditSubanalyses {
410            dead_code,
411            duplication,
412            complexity: run_health(&options.complexity)?,
413        })
414    })
415}
416
417fn run_dead_code_and_duplication_with_project_artifacts(
418    input: ProjectArtifactAuditInput<'_>,
419) -> ProgrammaticResult<(
420    crate::DeadCodeProgrammaticOutput,
421    crate::DuplicationProgrammaticOutput,
422    Option<DeadCodeAnalysisArtifacts>,
423    Option<fallow_engine::duplicates::DuplicationReport>,
424)> {
425    let dupes_config = super::duplication::build_dupes_config(
426        input.duplication_options,
427        &input.session.config().duplicates,
428    );
429    let section_start = Instant::now();
430    let project = input
431        .session
432        .analyze_project_with_artifacts(
433            &dupes_config,
434            ProjectAnalysisArtifactOptions {
435                retain_complexity_artifacts: input.retain_dead_code_artifacts,
436                retain_graph: input.retain_dead_code_artifacts,
437                changed_files: input.changed_files.cloned(),
438                collect_source_fingerprints: false,
439            },
440        )
441        .map_err(|err| {
442            ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
443                .with_code("FALLOW_AUDIT_FAILED")
444                .with_context("audit")
445        })?;
446    let duplication_artifacts = input
447        .retain_duplication_artifacts
448        .then(|| project.duplication.clone());
449    let dead_code = super::dead_code::run_dead_code_from_artifacts(
450        input.dead_code_options,
451        input.resolved,
452        input.session,
453        input.changed_files,
454        project.dead_code,
455        section_start,
456    )?;
457    let duplication = super::duplication::run_duplication_report_with_session(
458        input.duplication_options,
459        input.resolved,
460        input.session,
461        project.duplication,
462        section_start,
463    )?;
464    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
465        output: dead_code,
466        artifacts,
467    } = dead_code;
468    let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
469    Ok((
470        dead_code,
471        duplication,
472        dead_code_artifacts,
473        duplication_artifacts,
474    ))
475}
476
477#[derive(Clone, Copy)]
478struct ProjectArtifactAuditInput<'a> {
479    dead_code_options: &'a DeadCodeOptions,
480    duplication_options: &'a DuplicationOptions,
481    resolved: &'a ProgrammaticAnalysisContext,
482    session: &'a AnalysisSession,
483    changed_files: Option<&'a FxHashSet<PathBuf>>,
484    retain_dead_code_artifacts: bool,
485    retain_duplication_artifacts: bool,
486}
487
488fn run_all_audit_subanalyses_with_project_artifacts(
489    dead_code_options: &DeadCodeOptions,
490    duplication_options: &DuplicationOptions,
491    complexity_options: &ComplexityOptions,
492    resolved: &ProgrammaticAnalysisContext,
493    session: &AnalysisSession,
494    changed_files: Option<&FxHashSet<PathBuf>>,
495) -> ProgrammaticResult<AuditSubanalyses> {
496    let retain_dead_code_artifacts =
497        health_may_consume_dead_code_artifacts(complexity_options, session.config());
498    let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
499    let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
500        run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
501            dead_code_options,
502            duplication_options,
503            resolved,
504            session,
505            changed_files,
506            retain_dead_code_artifacts,
507            retain_duplication_artifacts,
508        })?;
509    let complexity = run_health_with_session_artifacts(
510        complexity_options,
511        resolved,
512        session,
513        changed_files,
514        dead_code_artifacts,
515        duplication_artifacts,
516    )?;
517    Ok(AuditSubanalyses {
518        dead_code,
519        duplication,
520        complexity,
521    })
522}
523
524fn run_dead_code_and_health_with_session(
525    dead_code_options: &DeadCodeOptions,
526    complexity_options: &ComplexityOptions,
527    resolved: &ProgrammaticAnalysisContext,
528    session: &AnalysisSession,
529    changed_files: Option<&FxHashSet<PathBuf>>,
530) -> ProgrammaticResult<(
531    crate::DeadCodeProgrammaticOutput,
532    crate::HealthProgrammaticOutput,
533)> {
534    let reuse_dead_code_artifacts =
535        health_may_consume_dead_code_artifacts(complexity_options, session.config());
536    let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
537        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
538            dead_code_options,
539            resolved,
540            session,
541            changed_files,
542            |_| {},
543            Instant::now(),
544        )?;
545        (dead_code.output, Some(dead_code.artifacts))
546    } else {
547        (
548            super::dead_code::run_dead_code_with_session(
549                dead_code_options,
550                resolved,
551                session,
552                changed_files,
553                |_| {},
554                Instant::now(),
555            )?,
556            None,
557        )
558    };
559    let complexity = run_health_with_session_artifacts(
560        complexity_options,
561        resolved,
562        session,
563        changed_files,
564        dead_code_artifacts,
565        None,
566    )?;
567    Ok((dead_code, complexity))
568}
569
570fn load_programmatic_audit_config(
571    resolved: &ProgrammaticAnalysisContext,
572) -> ProgrammaticResult<fallow_config::ResolvedConfig> {
573    fallow_engine::project_config::config_for_project_analysis(
574        resolved.root(),
575        resolved.config_path().as_deref(),
576        ProjectConfigOptions {
577            output: OutputFormat::Json,
578            no_cache: resolved.no_cache(),
579            threads: resolved.threads(),
580            production_override: resolved.production_override(),
581            quiet: true,
582            analysis: ProductionAnalysis::DeadCode,
583            allow_remote_extends: resolved.allow_remote_extends(),
584        },
585    )
586    .map(|project| project.config)
587    .map_err(|err| {
588        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
589            .with_code("FALLOW_CONFIG_LOAD_FAILED")
590            .with_context("analysis.configPath")
591    })
592}
593
594fn build_programmatic_audit_comparison(
595    analyses: &AuditSubanalyses,
596    config: &fallow_config::ResolvedConfig,
597    base: Option<&AuditRuntimeKeySnapshot>,
598) -> crate::audit_keys::AuditComparison {
599    let dupe_keys = analyses
600        .duplication
601        .output
602        .report
603        .clone_groups
604        .iter()
605        .map(|group| crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root))
606        .collect();
607    let styling_keys = analyses
608        .complexity
609        .report
610        .styling_findings
611        .iter()
612        .map(|finding| crate::audit_keys::styling_finding_key(finding, &analyses.complexity.root))
613        .collect();
614    crate::audit_keys::AuditComparison::build(crate::audit_keys::AuditComparisonInput {
615        results: &analyses.dead_code.output.results,
616        config,
617        root: &analyses.dead_code.root,
618        health: &analyses.complexity.report,
619        health_root: &analyses.complexity.root,
620        dupe_keys,
621        styling_keys,
622        base_dead_code: base.map(|snapshot| &snapshot.public.dead_code),
623        base_health: base.map(|snapshot| &snapshot.public.health),
624        base_dupes: base.map(|snapshot| &snapshot.public.dupes),
625        base_styling: base.map(|snapshot| &snapshot.styling),
626    })
627}
628
629/// Demote introduced clone groups whose instances contain no added lines from
630/// the merge-base worktree diff: no instance range contains an added line, so
631/// the changeset did not write the duplicated text and only the group's
632/// attribution key changed because the changeset
633/// removed code elsewhere. Keeps the new-only gate from failing a
634/// clone-removal refactor on duplication it did not write (issue #2164).
635fn demote_preexisting_dupe_introductions(
636    comparison: &mut crate::audit_keys::AuditComparison,
637    analyses: &AuditSubanalyses,
638    base_ref: &str,
639) {
640    if comparison.dupes.introduced_count() == 0 {
641        return;
642    }
643    let root = &analyses.duplication.root;
644    let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) else {
645        return;
646    };
647    let index = fallow_output::DiffIndex::from_unified_diff(&diff);
648    let demote = crate::audit_keys::preexisting_dupe_group_keys(
649        analyses
650            .duplication
651            .output
652            .report
653            .clone_groups
654            .iter()
655            .map(|group| &group.group),
656        root,
657        &index,
658    );
659    comparison.dupes.demote_introductions(&demote);
660}
661
662fn build_programmatic_audit_summary(
663    analyses: &AuditSubanalyses,
664    comparison: &crate::audit_keys::AuditComparison,
665) -> AuditSummary {
666    let dead_code_issues = comparison.dead_code.visible_count();
667    AuditSummary {
668        dead_code_issues,
669        dead_code_has_errors: comparison.dead_code.has_errors(),
670        complexity_findings: analyses.complexity.report.findings.len(),
671        max_cyclomatic: analyses
672            .complexity
673            .report
674            .findings
675            .iter()
676            .map(|finding| finding.cyclomatic)
677            .max(),
678        duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
679    }
680}
681
682fn styling_finding_gates(rules: &fallow_config::RulesConfig, code: &str) -> bool {
683    let severity = match code {
684        "css-token-drift" => rules.css_token_drift,
685        "css-duplicate-block" => rules.css_duplicate_block,
686        "css-selector-complexity" => rules.css_selector_complexity,
687        "css-dead-surface" => rules.css_dead_surface,
688        "css-broken-reference" => rules.css_broken_reference,
689        _ => fallow_config::Severity::Warn,
690    };
691    severity == fallow_config::Severity::Error
692}
693
694fn comparison_verdict(
695    gate: AuditGate,
696    summary: &AuditSummary,
697    duplication: &crate::DuplicationProgrammaticOutput,
698    complexity: &crate::HealthProgrammaticOutput,
699    config: &fallow_config::ResolvedConfig,
700    comparison: &crate::audit_keys::AuditComparison,
701) -> AuditVerdict {
702    let new_only = matches!(gate, AuditGate::NewOnly);
703    let dead_code_errors = if new_only {
704        comparison.dead_code.has_introduced_errors()
705    } else {
706        comparison.dead_code.has_errors()
707    };
708    let dead_code_warnings = if new_only {
709        comparison.dead_code.has_introduced_warnings()
710    } else {
711        comparison
712            .dead_code
713            .records()
714            .iter()
715            .any(|record| record.effective_severity == fallow_config::Severity::Warn)
716    };
717    let complexity_findings = if new_only {
718        comparison.health.introduced_count()
719    } else {
720        summary.complexity_findings
721    };
722    let styling_errors = complexity
723        .report
724        .styling_findings
725        .iter()
726        .zip(comparison.styling.introduced())
727        .any(|(finding, introduced)| {
728            (!new_only || introduced) && styling_finding_gates(&config.rules, &finding.code)
729        });
730    if dead_code_errors || complexity_findings > 0 || styling_errors {
731        return AuditVerdict::Fail;
732    }
733    let duplication_findings = if new_only {
734        comparison.dupes.introduced_count()
735    } else {
736        summary.duplication_clone_groups
737    };
738    if duplication_findings > 0 {
739        let pct = duplication.output.report.stats.duplication_percentage;
740        if duplication.threshold > 0.0 && pct > duplication.threshold {
741            return AuditVerdict::Fail;
742        }
743        return AuditVerdict::Warn;
744    }
745    if dead_code_warnings {
746        return AuditVerdict::Warn;
747    }
748    AuditVerdict::Pass
749}
750
751fn comparison_attribution(
752    gate: AuditGate,
753    comparison: &crate::audit_keys::AuditComparison,
754    has_base: bool,
755) -> AuditAttribution {
756    if !has_base {
757        return AuditAttribution {
758            gate,
759            ..AuditAttribution::default()
760        };
761    }
762    AuditAttribution {
763        gate,
764        dead_code_introduced: comparison.dead_code.introduced_count(),
765        dead_code_inherited: comparison.dead_code.inherited_count(),
766        complexity_introduced: comparison.health.introduced_count(),
767        complexity_inherited: comparison.health.inherited_count(),
768        duplication_introduced: comparison.dupes.introduced_count(),
769        duplication_inherited: comparison.dupes.inherited_count(),
770    }
771}
772
773fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditRuntimeKeySnapshot {
774    let styling =
775        crate::audit_keys::styling_keys(&analyses.complexity.report, &analyses.complexity.root);
776    let mut health =
777        crate::audit_keys::health_keys(&analyses.complexity.report, &analyses.complexity.root);
778    health.extend(styling.iter().cloned());
779    AuditRuntimeKeySnapshot {
780        public: AuditProgrammaticKeySnapshot {
781            dead_code: crate::audit_keys::dead_code_keys(
782                &analyses.dead_code.output.results,
783                &analyses.dead_code.root,
784            ),
785            health,
786            dupes: analyses
787                .duplication
788                .output
789                .report
790                .clone_groups
791                .iter()
792                .map(|group| {
793                    crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
794                })
795                .collect(),
796        },
797        styling,
798    }
799}
800
801fn compute_base_snapshot(
802    options: &AuditOptions,
803    base_ref: &str,
804) -> ProgrammaticResult<AuditRuntimeKeySnapshot> {
805    let current_root = analysis_root_from_options(options)?;
806    let worktree = TemporaryBaseWorktree::create(&current_root, base_ref).map_err(|err| {
807        ProgrammaticError::new(err.to_string(), 2)
808            .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
809            .with_context("audit.base")
810    })?;
811    let base_root = match repo_refs::resolve_base_analysis_root(&current_root, worktree.path()) {
812        repo_refs::BaseAnalysisRoot::Present(root) => root,
813        // A root the base commit does not contain (a package added on the
814        // branch) has an empty base snapshot, so every finding under it is
815        // introduced. That matches the CLI, which analyzes the same absent
816        // directory and finds nothing there.
817        repo_refs::BaseAnalysisRoot::NewInHead(_) => {
818            return Ok(AuditRuntimeKeySnapshot::default());
819        }
820    };
821    let current_config_path = options
822        .analysis
823        .config_path
824        .clone()
825        .or_else(|| fallow_config::FallowConfig::find_config_path(&current_root));
826    let base_analysis = AnalysisOptions {
827        root: Some(base_root),
828        config_path: current_config_path,
829        changed_since: None,
830        explain: false,
831        ..options.analysis.clone()
832    };
833    let base_options = base_snapshot_options(options, &current_root);
834    let base = run_audit_subanalyses(&base_options, &base_analysis, None, true)?;
835    Ok(snapshot_from_analyses(&base))
836}
837
838/// Audit options for the base-worktree pass, with Istanbul coverage inputs
839/// remapped onto that worktree.
840///
841/// The coverage file lives in (and its recorded paths point at) the HEAD
842/// checkout, while the base pass analyzes a temporary worktree. The coverage
843/// path is resolved against the canonical HEAD root so the base pass reads
844/// the same file, and when no explicit `coverage_root` exists that root
845/// becomes the strip prefix so every entry rebases onto the base worktree;
846/// otherwise base CRAP silently degrades to the reachability estimate and
847/// unchanged functions flip to `introduced` (#2347). Without explicit
848/// coverage, the head pass auto-detects `coverage/coverage-final.json`
849/// against the HEAD root, which the base worktree never materializes; the
850/// same auto-detection runs here so both passes score from the same map. An
851/// explicit `coverage_root` is forwarded unchanged: the base pass rebases it
852/// onto its own root. The canonical root serves both the path resolution and
853/// the default prefix, so a relative `analysis.root` cannot make the two
854/// mechanisms disagree.
855fn base_snapshot_options(options: &AuditOptions, current_root: &Path) -> AuditOptions {
856    let canonical_root =
857        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
858    let coverage = options.coverage.as_deref().map_or_else(
859        || fallow_engine::health::scoring::auto_detect_coverage(&canonical_root),
860        |coverage| {
861            Some(fallow_engine::health::scoring::resolve_relative_to_root(
862                coverage,
863                Some(&canonical_root),
864            ))
865        },
866    );
867    let Some(coverage) = coverage else {
868        return options.clone();
869    };
870    let mut base_options = options.clone();
871    base_options.coverage = Some(coverage);
872    if base_options.coverage_root.is_none() {
873        base_options.coverage_root = Some(canonical_root);
874    }
875    base_options
876}
877
878fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
879    match options.analysis.root.clone() {
880        Some(root) => Ok(root),
881        None => std::env::current_dir().map_err(|err| {
882            ProgrammaticError::new(
883                format!("failed to resolve current working directory: {err}"),
884                2,
885            )
886            .with_code("FALLOW_CWD_UNAVAILABLE")
887            .with_context("analysis.root")
888        }),
889    }
890}
891
892fn audit_next_steps(
893    dead_code: &crate::DeadCodeProgrammaticOutput,
894    complexity: &crate::HealthProgrammaticOutput,
895) -> Vec<NextStep> {
896    let input = fallow_output::build_audit_next_steps_input(
897        Some((&dead_code.output.results, dead_code.root.as_path())),
898        Some(&complexity.report),
899        crate::next_steps::suggestions_enabled(),
900    );
901    build_audit_next_steps(&input)
902}
903
904fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
905    fallow_engine::validate::validate_git_ref(value)
906        .map(|_| ())
907        .map_err(|err| {
908            ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
909                .with_code("FALLOW_INVALID_GIT_REF")
910                .with_context(context)
911        })
912}
913
914fn audit_base_env_override() -> Option<String> {
915    std::env::var("FALLOW_AUDIT_BASE")
916        .ok()
917        .map(|value| value.trim().to_string())
918        .filter(|value| !value.is_empty())
919}
920
921#[cfg(test)]
922mod tests {
923    use std::process::Command;
924
925    use fallow_config::{AuditGate, FallowConfig, HealthConfig};
926    use fallow_types::output_format::OutputFormat;
927
928    use super::*;
929
930    fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
931        FallowConfig {
932            health: HealthConfig {
933                max_crap,
934                ..HealthConfig::default()
935            },
936            ..FallowConfig::default()
937        }
938        .resolve(
939            std::env::temp_dir().join("fallow-api-runtime-test"),
940            OutputFormat::Json,
941            1,
942            true,
943            true,
944            None,
945        )
946    }
947
948    #[test]
949    fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
950        let options = ComplexityOptions {
951            complexity: true,
952            ..ComplexityOptions::default()
953        };
954        let config = resolved_config_with_max_crap(0.0);
955
956        assert!(!health_may_consume_dead_code_artifacts(&options, &config));
957    }
958
959    #[test]
960    fn audit_health_artifact_reuse_tracks_config_max_crap() {
961        let options = ComplexityOptions {
962            complexity: true,
963            ..ComplexityOptions::default()
964        };
965        let config = resolved_config_with_max_crap(30.0);
966
967        assert!(health_may_consume_dead_code_artifacts(&options, &config));
968    }
969
970    #[test]
971    fn audit_health_artifact_reuse_tracks_file_score_inputs() {
972        let config = resolved_config_with_max_crap(0.0);
973        for options in [
974            ComplexityOptions {
975                file_scores: true,
976                ..ComplexityOptions::default()
977            },
978            ComplexityOptions {
979                coverage_gaps: true,
980                ..ComplexityOptions::default()
981            },
982            ComplexityOptions {
983                targets: true,
984                ..ComplexityOptions::default()
985            },
986            ComplexityOptions {
987                score: true,
988                ..ComplexityOptions::default()
989            },
990            ComplexityOptions {
991                max_crap: Some(30.0),
992                complexity: true,
993                ..ComplexityOptions::default()
994            },
995        ] {
996            assert!(health_may_consume_dead_code_artifacts(&options, &config));
997        }
998    }
999
1000    #[test]
1001    fn audit_analysis_preserves_explicit_false_production_override() {
1002        let options = AuditOptions {
1003            production: false,
1004            analysis: AnalysisOptions {
1005                production: true,
1006                production_override: Some(false),
1007                ..AnalysisOptions::default()
1008            },
1009            ..AuditOptions::default()
1010        };
1011
1012        let analysis = analysis_options_for_audit(&options, "HEAD");
1013
1014        assert_eq!(analysis.production_override, Some(false));
1015        assert!(!analysis.production);
1016    }
1017
1018    #[test]
1019    fn audit_health_duplication_reuse_tracks_score_and_targets() {
1020        for options in [
1021            ComplexityOptions {
1022                score: true,
1023                ..ComplexityOptions::default()
1024            },
1025            ComplexityOptions {
1026                targets: true,
1027                ..ComplexityOptions::default()
1028            },
1029        ] {
1030            assert!(health_may_consume_duplication_report(&options));
1031        }
1032
1033        assert!(!health_may_consume_duplication_report(&ComplexityOptions {
1034            complexity: true,
1035            ..ComplexityOptions::default()
1036        }));
1037    }
1038
1039    #[test]
1040    fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
1041        let project = audit_fixture();
1042        let output = run_audit(&AuditOptions {
1043            analysis: AnalysisOptions {
1044                root: Some(project.path().to_path_buf()),
1045                no_cache: true,
1046                explain: true,
1047                ..AnalysisOptions::default()
1048            },
1049            base: Some("HEAD".to_string()),
1050            gate: AuditGate::NewOnly,
1051            ..AuditOptions::default()
1052        })
1053        .expect("audit output");
1054
1055        assert_eq!(output.verdict, AuditVerdict::Fail);
1056        assert_eq!(output.summary.dead_code_issues, 1);
1057        assert_eq!(output.attribution.dead_code_introduced, 1);
1058        assert!(output.base_snapshot.is_some());
1059
1060        let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
1061        assert_eq!(json["schema_version"], fallow_output::AUDIT_SCHEMA_VERSION);
1062        assert_eq!(
1063            json["dead_code"]["unused_files"][0]["path"],
1064            "src/feature.ts"
1065        );
1066        assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
1067    }
1068
1069    #[test]
1070    fn run_audit_warn_only_dead_code_matches_cli_verdict_semantics() {
1071        let project = audit_fixture();
1072        std::fs::write(
1073            project.path().join(".fallowrc.json"),
1074            r#"{"rules":{"unused-files":"warn"}}"#,
1075        )
1076        .expect("write config");
1077
1078        let output = run_audit(&AuditOptions {
1079            analysis: AnalysisOptions {
1080                root: Some(project.path().to_path_buf()),
1081                no_cache: true,
1082                ..AnalysisOptions::default()
1083            },
1084            base: Some("HEAD".to_string()),
1085            gate: AuditGate::All,
1086            ..AuditOptions::default()
1087        })
1088        .expect("audit output");
1089
1090        assert_eq!(output.verdict, AuditVerdict::Warn);
1091        assert!(!output.summary.dead_code_has_errors);
1092    }
1093
1094    #[test]
1095    fn run_audit_styling_error_matches_cli_for_new_only_and_all_gates() {
1096        let project = audit_styling_fixture();
1097        let root = project.path();
1098        std::fs::write(
1099            root.join("src/styles.css"),
1100            "#app .legacy .title { color: red; }\n.plain { color: blue; }\n",
1101        )
1102        .expect("write inherited-only change");
1103
1104        let all = run_audit(&AuditOptions {
1105            analysis: AnalysisOptions {
1106                root: Some(root.to_path_buf()),
1107                no_cache: true,
1108                ..AnalysisOptions::default()
1109            },
1110            base: Some("HEAD".to_string()),
1111            gate: AuditGate::All,
1112            ..AuditOptions::default()
1113        })
1114        .expect("all-gate audit");
1115        assert_eq!(all.verdict, AuditVerdict::Fail);
1116
1117        let inherited_only = run_audit(&AuditOptions {
1118            analysis: AnalysisOptions {
1119                root: Some(root.to_path_buf()),
1120                no_cache: true,
1121                ..AnalysisOptions::default()
1122            },
1123            base: Some("HEAD".to_string()),
1124            gate: AuditGate::NewOnly,
1125            ..AuditOptions::default()
1126        })
1127        .expect("new-only inherited audit");
1128        assert_eq!(inherited_only.verdict, AuditVerdict::Pass);
1129        assert!(inherited_only.base_snapshot.is_some());
1130        let inherited_json =
1131            crate::serialize_audit_programmatic_json(inherited_only).expect("inherited audit JSON");
1132        assert_eq!(
1133            inherited_json["complexity"]["styling_findings"][0]["introduced"],
1134            false
1135        );
1136
1137        std::fs::write(
1138            root.join("src/styles.css"),
1139            "#app .legacy .title { color: red; }\n.plain { color: blue; }\n#app .introduced .title { color: green; }\n",
1140        )
1141        .expect("write introduced styling change");
1142        let introduced = run_audit(&AuditOptions {
1143            analysis: AnalysisOptions {
1144                root: Some(root.to_path_buf()),
1145                no_cache: true,
1146                ..AnalysisOptions::default()
1147            },
1148            base: Some("HEAD".to_string()),
1149            gate: AuditGate::NewOnly,
1150            ..AuditOptions::default()
1151        })
1152        .expect("new-only introduced audit");
1153        assert_eq!(introduced.verdict, AuditVerdict::Fail);
1154        let introduced_json =
1155            crate::serialize_audit_programmatic_json(introduced).expect("introduced audit JSON");
1156        let styling = introduced_json["complexity"]["styling_findings"]
1157            .as_array()
1158            .expect("styling findings");
1159        assert!(
1160            styling
1161                .iter()
1162                .any(|finding| finding["line"] == 1 && finding["introduced"] == false)
1163        );
1164        assert!(
1165            styling
1166                .iter()
1167                .any(|finding| finding["line"] == 3 && finding["introduced"] == true)
1168        );
1169    }
1170
1171    /// #2347: a pre-existing high-CRAP function must stay `introduced: false`
1172    /// when Istanbul coverage is supplied and an unrelated edit touches its
1173    /// file. The base snapshot analyzes a temporary worktree, so the coverage
1174    /// entries (recorded against the HEAD checkout) must be rebased onto that
1175    /// worktree; otherwise the base side falls back to the reachability
1176    /// estimate, scores below threshold, and the unchanged finding flips the
1177    /// new-only gate.
1178    #[test]
1179    fn run_audit_coverage_keeps_unchanged_function_inherited() {
1180        let project = tempfile::tempdir().expect("project");
1181        let root = project.path();
1182        std::fs::create_dir_all(root.join("src")).expect("create src");
1183        std::fs::write(
1184            root.join("package.json"),
1185            r#"{"name":"audit-api-coverage","type":"module","main":"src/index.ts","devDependencies":{"vitest":"^3.0.0"}}"#,
1186        )
1187        .expect("write package");
1188        std::fs::write(root.join("src/index.ts"), "console.log('entry');\n").expect("write entry");
1189        std::fs::write(
1190            root.join("src/branchy.ts"),
1191            "export function branchy(n: number): number {\n\
1192             \x20 if (n < 0) return -1;\n\
1193             \x20 if (n === 0) return 0;\n\
1194             \x20 if (n < 10) return 1;\n\
1195             \x20 if (n < 100) return 2;\n\
1196             \x20 if (n < 1000) return 3;\n\
1197             \x20 if (n < 10000) return 4;\n\
1198             \x20 return 5;\n\
1199             }\n",
1200        )
1201        .expect("write branchy");
1202        std::fs::write(
1203            root.join("src/branchy.test.ts"),
1204            "import { branchy } from './branchy';\nbranchy(1);\n",
1205        )
1206        .expect("write test reference");
1207        git(root, &["init"]);
1208        git(root, &["add", "."]);
1209        git(
1210            root,
1211            &[
1212                "-c",
1213                "user.email=test@example.com",
1214                "-c",
1215                "user.name=Test",
1216                "-c",
1217                "commit.gpgsign=false",
1218                "commit",
1219                "-m",
1220                "initial",
1221            ],
1222        );
1223        let mut source = std::fs::read_to_string(root.join("src/branchy.ts")).expect("branchy");
1224        source.push_str("branchy(-1);\n");
1225        std::fs::write(root.join("src/branchy.ts"), source).expect("append unrelated statement");
1226
1227        std::fs::create_dir_all(root.join("artifacts")).expect("create artifacts");
1228        let recorded = root.join("src/branchy.ts");
1229        let recorded = recorded.to_string_lossy().replace('\\', "\\\\");
1230        std::fs::write(
1231            root.join("artifacts/coverage-final.json"),
1232            format!(
1233                r#"{{"{recorded}":{{"path":"{recorded}","statementMap":{{}},"fnMap":{{"0":{{"name":"branchy","line":1,"decl":{{"start":{{"line":1,"column":16}},"end":{{"line":1,"column":23}}}},"loc":{{"start":{{"line":1,"column":44}},"end":{{"line":9,"column":1}}}}}}}},"branchMap":{{}},"s":{{}},"f":{{"0":0}},"b":{{}}}}}}"#
1234            ),
1235        )
1236        .expect("write coverage");
1237
1238        let output = run_audit(&AuditOptions {
1239            analysis: AnalysisOptions {
1240                root: Some(root.to_path_buf()),
1241                no_cache: true,
1242                ..AnalysisOptions::default()
1243            },
1244            base: Some("HEAD".to_string()),
1245            gate: AuditGate::NewOnly,
1246            max_crap: Some(10.0),
1247            coverage: Some(root.join("artifacts/coverage-final.json")),
1248            ..AuditOptions::default()
1249        })
1250        .expect("audit output");
1251
1252        assert_eq!(output.attribution.complexity_introduced, 0);
1253        assert_eq!(output.attribution.complexity_inherited, 1);
1254        assert_eq!(output.verdict, AuditVerdict::Pass);
1255        let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
1256        let findings = json["complexity"]["findings"]
1257            .as_array()
1258            .expect("complexity findings");
1259        let branchy = findings
1260            .iter()
1261            .find(|finding| finding["name"] == "branchy")
1262            .expect("branchy reported above the CRAP threshold with 0% measured coverage");
1263        assert_eq!(branchy["introduced"], false);
1264        assert_eq!(branchy["coverage_source"], "istanbul");
1265    }
1266
1267    #[test]
1268    fn audit_production_mode_branches_preserve_per_section_workspace_scope() {
1269        let project = audit_workspace_modes_fixture();
1270
1271        for mask in 0_u8..8 {
1272            let production_dead_code = mask & 0b001 != 0;
1273            let production_health = mask & 0b010 != 0;
1274            let production_dupes = mask & 0b100 != 0;
1275            let output = run_audit(&AuditOptions {
1276                analysis: AnalysisOptions {
1277                    root: Some(project.path().to_path_buf()),
1278                    workspace: Some(vec!["@audit/a".to_string()]),
1279                    no_cache: true,
1280                    ..AnalysisOptions::default()
1281                },
1282                base: Some("HEAD".to_string()),
1283                gate: AuditGate::All,
1284                production_dead_code: Some(production_dead_code),
1285                production_health: Some(production_health),
1286                production_dupes: Some(production_dupes),
1287                include_entry_exports: true,
1288                ..AuditOptions::default()
1289            })
1290            .unwrap_or_else(|error| panic!("audit mask {mask:03b} failed: {error}"));
1291            let json = crate::serialize_audit_programmatic_json(output)
1292                .unwrap_or_else(|error| panic!("serialize mask {mask:03b}: {error}"));
1293
1294            let dead_code = json["dead_code"].to_string();
1295            let complexity = json["complexity"].to_string();
1296            let duplication = json["duplication"].to_string();
1297            assert_eq!(
1298                dead_code.contains("mode-sentinel.test.ts"),
1299                !production_dead_code,
1300                "dead-code scope mismatch for mask {mask:03b}: {dead_code}"
1301            );
1302            assert_eq!(
1303                complexity.contains("mode-sentinel.test.ts"),
1304                !production_health,
1305                "health scope mismatch for mask {mask:03b}: {complexity}"
1306            );
1307            assert_eq!(
1308                duplication.contains("mode-sentinel.test.ts"),
1309                !production_dupes,
1310                "duplication scope mismatch for mask {mask:03b}: {duplication}"
1311            );
1312
1313            let rendered = json.to_string();
1314            assert!(
1315                !rendered.contains("packages/b"),
1316                "workspace B leaked into mask {mask:03b}: {rendered}"
1317            );
1318        }
1319    }
1320
1321    #[test]
1322    fn empty_audit_output_uses_resolved_root_for_head_sha() {
1323        let project = audit_fixture();
1324        let output = empty_audit_output(
1325            &AuditOptions {
1326                analysis: AnalysisOptions {
1327                    root: None,
1328                    ..AnalysisOptions::default()
1329                },
1330                base: Some("HEAD".to_string()),
1331                gate: AuditGate::NewOnly,
1332                ..AuditOptions::default()
1333            },
1334            ResolvedAuditBase {
1335                git_ref: "HEAD".to_string(),
1336                description: None,
1337            },
1338            project.path(),
1339            0,
1340            std::time::Duration::ZERO,
1341        );
1342
1343        assert!(output.head_sha.is_some());
1344    }
1345
1346    fn audit_fixture() -> tempfile::TempDir {
1347        let project = tempfile::tempdir().expect("project");
1348        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1349        std::fs::write(
1350            project.path().join("package.json"),
1351            r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
1352        )
1353        .expect("write package");
1354        std::fs::write(
1355            project.path().join("src/index.ts"),
1356            "console.log('entry');\n",
1357        )
1358        .expect("write entry");
1359        git(project.path(), &["init"]);
1360        git(project.path(), &["add", "."]);
1361        git(
1362            project.path(),
1363            &[
1364                "-c",
1365                "user.email=test@example.com",
1366                "-c",
1367                "user.name=Test",
1368                "-c",
1369                "commit.gpgsign=false",
1370                "commit",
1371                "-m",
1372                "initial",
1373            ],
1374        );
1375        std::fs::write(
1376            project.path().join("src/feature.ts"),
1377            "export const unused = 1;\n",
1378        )
1379        .expect("write changed source");
1380        project
1381    }
1382
1383    fn audit_styling_fixture() -> tempfile::TempDir {
1384        let project = tempfile::tempdir().expect("project");
1385        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1386        std::fs::write(
1387            project.path().join("package.json"),
1388            r#"{"name":"audit-api-styling","type":"module","main":"src/index.ts"}"#,
1389        )
1390        .expect("write package");
1391        std::fs::write(
1392            project.path().join(".fallowrc.json"),
1393            r#"{"rules":{"css-selector-complexity":"error"}}"#,
1394        )
1395        .expect("write config");
1396        std::fs::write(
1397            project.path().join("src/index.ts"),
1398            "console.log('entry');\n",
1399        )
1400        .expect("write entry");
1401        std::fs::write(
1402            project.path().join("src/styles.css"),
1403            "#app .legacy .title { color: red; }\n",
1404        )
1405        .expect("write inherited styling");
1406        git(project.path(), &["init"]);
1407        git(project.path(), &["add", "."]);
1408        git(
1409            project.path(),
1410            &[
1411                "-c",
1412                "user.email=test@example.com",
1413                "-c",
1414                "user.name=Test",
1415                "-c",
1416                "commit.gpgsign=false",
1417                "commit",
1418                "-m",
1419                "initial",
1420            ],
1421        );
1422        project
1423    }
1424
1425    fn audit_workspace_modes_fixture() -> tempfile::TempDir {
1426        let project = tempfile::tempdir().expect("project");
1427        std::fs::write(
1428            project.path().join("package.json"),
1429            r#"{"name":"audit-root","private":true,"workspaces":["packages/*"]}"#,
1430        )
1431        .expect("write root package");
1432        std::fs::write(
1433            project.path().join(".fallowrc.json"),
1434            r#"{
1435  "duplicates": {
1436    "minTokens": 10,
1437    "minLines": 2,
1438    "ignoreDefaults": false
1439  },
1440  "health": {
1441    "maxCyclomatic": 2,
1442    "maxCognitive": 2,
1443    "maxCrap": 2.0,
1444    "maxUnitSize": 3
1445  }
1446}"#,
1447        )
1448        .expect("write config");
1449
1450        for name in ["a", "b"] {
1451            let package = project.path().join("packages").join(name);
1452            std::fs::create_dir_all(package.join("src")).expect("create package source");
1453            std::fs::write(
1454                package.join("package.json"),
1455                format!(r#"{{"name":"@audit/{name}","type":"module","main":"src/index.ts"}}"#),
1456            )
1457            .expect("write package manifest");
1458            std::fs::write(
1459                package.join("src/index.ts"),
1460                format!("export const {name}Entry = true;\n"),
1461            )
1462            .expect("write package entry");
1463        }
1464
1465        git(project.path(), &["init"]);
1466        git(project.path(), &["add", "."]);
1467        git(
1468            project.path(),
1469            &[
1470                "-c",
1471                "user.email=test@example.com",
1472                "-c",
1473                "user.name=Test",
1474                "-c",
1475                "commit.gpgsign=false",
1476                "commit",
1477                "-m",
1478                "initial",
1479            ],
1480        );
1481
1482        let sentinel = r"export function auditModeSentinel(value: number) {
1483  let result = value;
1484  if (value > 0) result += 1;
1485  if (value > 1) result += 2;
1486  if (value > 2) result += 3;
1487  if (value > 3) result += 4;
1488  return result;
1489}
1490";
1491        for name in ["a", "b"] {
1492            let source = project.path().join("packages").join(name).join("src");
1493            std::fs::write(source.join("mode-sentinel.test.ts"), sentinel)
1494                .expect("write test sentinel");
1495            std::fs::write(source.join("mode-sentinel-copy.test.ts"), sentinel)
1496                .expect("write duplicate test sentinel");
1497        }
1498
1499        project
1500    }
1501
1502    fn git(root: &Path, args: &[&str]) {
1503        let status = Command::new("git")
1504            .args(args)
1505            .current_dir(root)
1506            .status()
1507            .expect("git command");
1508        assert!(status.success(), "git {args:?} failed");
1509    }
1510
1511    /// #2699: the auto-detect fallthrough gets the same ref validation as the
1512    /// explicit and environment paths, so a malformed detection surfaces as a
1513    /// base-ref error instead of a changed-files failure deeper in the run.
1514    #[test]
1515    fn resolve_audit_base_ref_validates_the_auto_detected_ref() {
1516        let project = tempfile::tempdir().expect("temp dir");
1517        let root = project.path();
1518        std::fs::write(root.join("index.ts"), "export const used = 1;\n").expect("write entry");
1519        git(root, &["init", "-b", "main"]);
1520        git(root, &["add", "."]);
1521        git(
1522            root,
1523            &[
1524                "-c",
1525                "user.email=test@example.com",
1526                "-c",
1527                "user.name=Test",
1528                "-c",
1529                "commit.gpgsign=false",
1530                "commit",
1531                "-m",
1532                "initial",
1533            ],
1534        );
1535        git(root, &["update-ref", "refs/remotes/origin/main", "main"]);
1536        git(
1537            root,
1538            &[
1539                "symbolic-ref",
1540                "refs/remotes/origin/HEAD",
1541                "refs/remotes/origin/main",
1542            ],
1543        );
1544        let options = AuditOptions {
1545            analysis: AnalysisOptions {
1546                root: Some(root.to_path_buf()),
1547                ..AnalysisOptions::default()
1548            },
1549            ..AuditOptions::default()
1550        };
1551
1552        let resolved = resolve_audit_base_ref(&options).expect("base ref resolves");
1553
1554        assert!(
1555            fallow_engine::validate::validate_git_ref(&resolved.git_ref).is_ok(),
1556            "auto-detected ref must be usable as a git ref: {:?}",
1557            resolved.git_ref
1558        );
1559        assert_eq!(
1560            resolved.description.as_deref(),
1561            Some("merge-base with origin/main")
1562        );
1563    }
1564}