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