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