Skip to main content

fallow_api/runtime/
audit.rs

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