Skip to main content

fallow_api/runtime/
audit.rs

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