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