1use std::path::{Path, PathBuf};
2use std::process::Command;
3use std::time::{Instant, SystemTime};
4
5use fallow_config::AuditGate;
6use fallow_engine::{
7 changed_files::clear_ambient_git_env, dead_code::DeadCodeAnalysisArtifacts,
8 project_analysis::ProjectAnalysisArtifactOptions, session::AnalysisSession,
9};
10use fallow_output::build_audit_next_steps;
11use fallow_types::output::NextStep;
12use rustc_hash::FxHashSet;
13
14use crate::{
15 AnalysisOptions, AuditAttribution, AuditOptions, AuditProgrammaticKeySnapshot,
16 AuditProgrammaticOutput, AuditSummary, AuditVerdict, ComplexityOptions, DeadCodeFilters,
17 DeadCodeOptions, DuplicationOptions, ProgrammaticError,
18 analysis_context::{
19 ProgrammaticAnalysisContext, changed_files_for_run, resolve_programmatic_analysis_context,
20 resolve_programmatic_analysis_context_deferred_workspace,
21 },
22};
23
24use super::{
25 ProgrammaticResult, health_may_consume_dead_code_artifacts,
26 health_may_consume_duplication_report, resolve_effective_production_modes, root_envelope_mode,
27 run_dead_code, run_duplication, run_health, run_health_with_session_artifacts,
28};
29
30pub fn run_audit(options: &AuditOptions) -> ProgrammaticResult<AuditProgrammaticOutput> {
37 validate_audit_api_options(options)?;
38 let start = Instant::now();
39 let resolved_base = resolve_audit_base_ref(options)?;
40 let analysis = analysis_options_for_audit(options, &resolved_base.git_ref);
41 let resolved = resolve_programmatic_analysis_context(&analysis)?;
42 let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
43 let changed_files_count = changed_files.len();
44
45 if changed_files.is_empty() {
46 return Ok(empty_audit_output(
47 options,
48 resolved_base,
49 resolved.root(),
50 changed_files_count,
51 start.elapsed(),
52 ));
53 }
54
55 let head = run_audit_subanalyses(options, &analysis, Some(&changed_files))?;
56 let current_snapshot = snapshot_from_analyses(&head);
57 let base_snapshot = if matches!(options.gate, AuditGate::NewOnly) {
58 Some(compute_base_snapshot(options, &resolved_base.git_ref)?)
59 } else {
60 None
61 };
62 let summary = build_programmatic_audit_summary(&head);
63 let attribution = compute_programmatic_audit_attribution(
64 options.gate,
65 ¤t_snapshot,
66 base_snapshot.as_ref(),
67 );
68 let verdict = compute_programmatic_audit_verdict(
69 options.gate,
70 &summary,
71 &head.duplication,
72 ¤t_snapshot,
73 base_snapshot.as_ref(),
74 );
75 let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
76
77 Ok(AuditProgrammaticOutput {
78 verdict,
79 summary,
80 attribution,
81 changed_files_count,
82 base_ref: resolved_base.git_ref,
83 base_description: resolved_base.description,
84 head_sha: get_head_sha(resolved.root()),
85 elapsed: start.elapsed(),
86 base_snapshot_skipped: None,
87 base_snapshot,
88 dead_code: Some(head.dead_code),
89 duplication: Some(head.duplication),
90 complexity: Some(head.complexity),
91 next_steps,
92 envelope_mode: root_envelope_mode(),
93 telemetry_analysis_run_id: None,
94 })
95}
96
97fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
98 if let Err(err) =
99 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
100 {
101 return Err(ProgrammaticError::new(err, 2)
102 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
103 .with_context("audit.coverageRoot"));
104 }
105 if options.runtime_coverage.is_some() {
106 return Err(ProgrammaticError::new(
107 "programmatic audit does not yet support runtime coverage; use the CLI path",
108 2,
109 )
110 .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
111 .with_context("audit.runtimeCoverage"));
112 }
113 Ok(())
114}
115
116#[derive(Debug, Clone)]
117pub(super) struct ResolvedAuditBase {
118 pub(super) git_ref: String,
119 pub(super) description: Option<String>,
120}
121
122pub(super) fn resolve_audit_base_ref(
123 options: &AuditOptions,
124) -> ProgrammaticResult<ResolvedAuditBase> {
125 if let Some(ref_str) = options
126 .base
127 .as_deref()
128 .or(options.analysis.changed_since.as_deref())
129 {
130 validate_git_ref(ref_str, "audit.base")?;
131 return Ok(ResolvedAuditBase {
132 git_ref: (*ref_str).to_string(),
133 description: None,
134 });
135 }
136 if let Some(env_ref) = audit_base_env_override() {
137 validate_git_ref(&env_ref, "FALLOW_AUDIT_BASE")?;
138 return Ok(ResolvedAuditBase {
139 description: Some(format!("FALLOW_AUDIT_BASE={env_ref}")),
140 git_ref: env_ref,
141 });
142 }
143 let root = options
144 .analysis
145 .root
146 .clone()
147 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
148 auto_detect_base_ref(&root).ok_or_else(|| {
149 ProgrammaticError::new(
150 "could not detect base branch. Set audit.base to specify the comparison target",
151 2,
152 )
153 .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
154 .with_context("audit.base")
155 })
156}
157
158fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
159 AnalysisOptions {
160 changed_since: Some(base_ref.to_string()),
161 production: options.production,
162 production_override: options.production.then_some(true),
163 ..options.analysis.clone()
164 }
165}
166
167fn analysis_with_production(
168 analysis: &AnalysisOptions,
169 production_override: Option<bool>,
170) -> AnalysisOptions {
171 AnalysisOptions {
172 production: production_override.unwrap_or(analysis.production),
173 production_override: production_override.or(analysis.production_override),
174 ..analysis.clone()
175 }
176}
177
178fn empty_audit_output(
179 options: &AuditOptions,
180 base: ResolvedAuditBase,
181 root: &Path,
182 changed_files_count: usize,
183 elapsed: std::time::Duration,
184) -> AuditProgrammaticOutput {
185 AuditProgrammaticOutput {
186 verdict: AuditVerdict::Pass,
187 summary: AuditSummary {
188 dead_code_issues: 0,
189 dead_code_has_errors: false,
190 complexity_findings: 0,
191 max_cyclomatic: None,
192 duplication_clone_groups: 0,
193 },
194 attribution: AuditAttribution {
195 gate: options.gate,
196 ..AuditAttribution::default()
197 },
198 changed_files_count,
199 base_ref: base.git_ref,
200 base_description: base.description,
201 head_sha: get_head_sha(root),
202 elapsed,
203 base_snapshot_skipped: None,
204 base_snapshot: None,
205 dead_code: None,
206 duplication: None,
207 complexity: None,
208 next_steps: Vec::new(),
209 envelope_mode: root_envelope_mode(),
210 telemetry_analysis_run_id: None,
211 }
212}
213
214struct AuditSubanalyses {
215 dead_code: crate::DeadCodeProgrammaticOutput,
216 duplication: crate::DuplicationProgrammaticOutput,
217 complexity: crate::HealthProgrammaticOutput,
218}
219
220fn run_audit_subanalyses(
221 options: &AuditOptions,
222 analysis: &AnalysisOptions,
223 changed_files: Option<&FxHashSet<PathBuf>>,
224) -> ProgrammaticResult<AuditSubanalyses> {
225 let dead_code_options = DeadCodeOptions {
226 analysis: analysis_with_production(analysis, options.production_dead_code),
227 filters: DeadCodeFilters::default(),
228 files: Vec::new(),
229 include_entry_exports: options.include_entry_exports,
230 };
231 let duplication_options = DuplicationOptions {
232 analysis: analysis_with_production(analysis, options.production_dupes),
233 ..DuplicationOptions::default()
234 };
235 let complexity_options = ComplexityOptions {
236 analysis: analysis_with_production(analysis, options.production_health),
237 max_crap: options.max_crap,
238 complexity: true,
239 css: options.css.unwrap_or(true),
240 css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
241 coverage: options.coverage.clone(),
242 coverage_root: options.coverage_root.clone(),
243 ..ComplexityOptions::default()
244 };
245 let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
246 let production_modes = resolve_effective_production_modes(
247 &resolved,
248 options.production_dead_code,
249 options.production_health,
250 options.production_dupes,
251 )?;
252
253 if production_modes.dead_code == production_modes.dupes
254 && production_modes.dead_code == production_modes.health
255 {
256 return resolved.install(|| {
257 let session = super::dead_code::load_dead_code_session(&dead_code_options, &resolved)?;
258 run_all_audit_subanalyses_with_project_artifacts(
259 &dead_code_options,
260 &duplication_options,
261 &complexity_options,
262 &resolved,
263 &session,
264 changed_files,
265 )
266 });
267 }
268
269 if production_modes.dead_code == production_modes.health {
270 return resolved.install(|| {
271 let session = super::dead_code::load_dead_code_session(&dead_code_options, &resolved)?;
272 let (dead_code, complexity) = run_dead_code_and_health_with_session(
273 &dead_code_options,
274 &complexity_options,
275 &resolved,
276 &session,
277 changed_files,
278 )?;
279 Ok(AuditSubanalyses {
280 dead_code,
281 duplication: run_duplication(&duplication_options)?,
282 complexity,
283 })
284 });
285 }
286
287 if production_modes.dead_code == production_modes.dupes {
288 return resolved.install(|| {
289 let session = super::dead_code::load_dead_code_session(&dead_code_options, &resolved)?;
290 let (dead_code, duplication, _, _) =
291 run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
292 dead_code_options: &dead_code_options,
293 duplication_options: &duplication_options,
294 resolved: &resolved,
295 session: &session,
296 changed_files,
297 retain_dead_code_artifacts: false,
298 retain_duplication_artifacts: false,
299 })?;
300 Ok(AuditSubanalyses {
301 dead_code,
302 duplication,
303 complexity: run_health(&complexity_options)?,
304 })
305 });
306 }
307
308 Ok(AuditSubanalyses {
309 dead_code: run_dead_code(&dead_code_options)?,
310 duplication: run_duplication(&duplication_options)?,
311 complexity: run_health(&complexity_options)?,
312 })
313}
314
315fn run_dead_code_and_duplication_with_project_artifacts(
316 input: ProjectArtifactAuditInput<'_>,
317) -> ProgrammaticResult<(
318 crate::DeadCodeProgrammaticOutput,
319 crate::DuplicationProgrammaticOutput,
320 Option<DeadCodeAnalysisArtifacts>,
321 Option<fallow_engine::duplicates::DuplicationReport>,
322)> {
323 let dupes_config = super::duplication::build_dupes_config(
324 input.duplication_options,
325 &input.session.config().duplicates,
326 );
327 let section_start = Instant::now();
328 let project = input
329 .session
330 .analyze_project_with_artifacts(
331 &dupes_config,
332 ProjectAnalysisArtifactOptions {
333 retain_complexity_artifacts: input.retain_dead_code_artifacts,
334 retain_graph: input.retain_dead_code_artifacts,
335 changed_files: input.changed_files.cloned(),
336 collect_source_fingerprints: false,
337 },
338 )
339 .map_err(|err| {
340 ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
341 .with_code("FALLOW_AUDIT_FAILED")
342 .with_context("audit")
343 })?;
344 let duplication_artifacts = input
345 .retain_duplication_artifacts
346 .then(|| project.duplication.clone());
347 let dead_code = super::dead_code::run_dead_code_from_artifacts(
348 input.dead_code_options,
349 input.resolved,
350 input.session,
351 input.changed_files,
352 project.dead_code,
353 section_start,
354 )?;
355 let duplication = super::duplication::run_duplication_report_with_session(
356 input.duplication_options,
357 input.resolved,
358 input.session,
359 project.duplication,
360 section_start,
361 )?;
362 let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
363 output: dead_code,
364 artifacts,
365 } = dead_code;
366 let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
367 Ok((
368 dead_code,
369 duplication,
370 dead_code_artifacts,
371 duplication_artifacts,
372 ))
373}
374
375#[derive(Clone, Copy)]
376struct ProjectArtifactAuditInput<'a> {
377 dead_code_options: &'a DeadCodeOptions,
378 duplication_options: &'a DuplicationOptions,
379 resolved: &'a ProgrammaticAnalysisContext,
380 session: &'a AnalysisSession,
381 changed_files: Option<&'a FxHashSet<PathBuf>>,
382 retain_dead_code_artifacts: bool,
383 retain_duplication_artifacts: bool,
384}
385
386fn run_all_audit_subanalyses_with_project_artifacts(
387 dead_code_options: &DeadCodeOptions,
388 duplication_options: &DuplicationOptions,
389 complexity_options: &ComplexityOptions,
390 resolved: &ProgrammaticAnalysisContext,
391 session: &AnalysisSession,
392 changed_files: Option<&FxHashSet<PathBuf>>,
393) -> ProgrammaticResult<AuditSubanalyses> {
394 let retain_dead_code_artifacts =
395 health_may_consume_dead_code_artifacts(complexity_options, session.config());
396 let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
397 let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
398 run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
399 dead_code_options,
400 duplication_options,
401 resolved,
402 session,
403 changed_files,
404 retain_dead_code_artifacts,
405 retain_duplication_artifacts,
406 })?;
407 let complexity = run_health_with_session_artifacts(
408 complexity_options,
409 resolved,
410 session,
411 changed_files,
412 dead_code_artifacts,
413 duplication_artifacts,
414 )?;
415 Ok(AuditSubanalyses {
416 dead_code,
417 duplication,
418 complexity,
419 })
420}
421
422fn run_dead_code_and_health_with_session(
423 dead_code_options: &DeadCodeOptions,
424 complexity_options: &ComplexityOptions,
425 resolved: &ProgrammaticAnalysisContext,
426 session: &AnalysisSession,
427 changed_files: Option<&FxHashSet<PathBuf>>,
428) -> ProgrammaticResult<(
429 crate::DeadCodeProgrammaticOutput,
430 crate::HealthProgrammaticOutput,
431)> {
432 let reuse_dead_code_artifacts =
433 health_may_consume_dead_code_artifacts(complexity_options, session.config());
434 let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
435 let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
436 dead_code_options,
437 resolved,
438 session,
439 changed_files,
440 |_| {},
441 Instant::now(),
442 )?;
443 (dead_code.output, Some(dead_code.artifacts))
444 } else {
445 (
446 super::dead_code::run_dead_code_with_session(
447 dead_code_options,
448 resolved,
449 session,
450 changed_files,
451 |_| {},
452 Instant::now(),
453 )?,
454 None,
455 )
456 };
457 let complexity = run_health_with_session_artifacts(
458 complexity_options,
459 resolved,
460 session,
461 changed_files,
462 dead_code_artifacts,
463 None,
464 )?;
465 Ok((dead_code, complexity))
466}
467
468fn build_programmatic_audit_summary(analyses: &AuditSubanalyses) -> AuditSummary {
469 let dead_code_issues = analyses.dead_code.output.results.total_issues();
470 AuditSummary {
471 dead_code_issues,
472 dead_code_has_errors: dead_code_issues > 0,
473 complexity_findings: analyses.complexity.report.findings.len(),
474 max_cyclomatic: analyses
475 .complexity
476 .report
477 .findings
478 .iter()
479 .map(|finding| finding.cyclomatic)
480 .max(),
481 duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
482 }
483}
484
485fn compute_programmatic_audit_verdict(
486 gate: AuditGate,
487 summary: &AuditSummary,
488 duplication: &crate::DuplicationProgrammaticOutput,
489 current: &AuditProgrammaticKeySnapshot,
490 base: Option<&AuditProgrammaticKeySnapshot>,
491) -> AuditVerdict {
492 if matches!(gate, AuditGate::NewOnly) {
493 return compute_programmatic_introduced_verdict(summary, duplication, current, base);
494 }
495 if summary.dead_code_has_errors || summary.complexity_findings > 0 {
496 return AuditVerdict::Fail;
497 }
498 if summary.duplication_clone_groups > 0 {
499 let pct = duplication.output.report.stats.duplication_percentage;
500 if duplication.threshold > 0.0 && pct > duplication.threshold {
501 return AuditVerdict::Fail;
502 }
503 return AuditVerdict::Warn;
504 }
505 AuditVerdict::Pass
506}
507
508fn compute_programmatic_introduced_verdict(
509 summary: &AuditSummary,
510 duplication: &crate::DuplicationProgrammaticOutput,
511 current: &AuditProgrammaticKeySnapshot,
512 base: Option<&AuditProgrammaticKeySnapshot>,
513) -> AuditVerdict {
514 let attribution = compute_programmatic_audit_attribution(AuditGate::NewOnly, current, base);
515 if attribution.dead_code_introduced > 0 || attribution.complexity_introduced > 0 {
516 return AuditVerdict::Fail;
517 }
518 if attribution.duplication_introduced > 0 {
519 let pct = duplication.output.report.stats.duplication_percentage;
520 if duplication.threshold > 0.0 && pct > duplication.threshold {
521 return AuditVerdict::Fail;
522 }
523 return AuditVerdict::Warn;
524 }
525 if summary.dead_code_issues == 0
526 && summary.complexity_findings == 0
527 && summary.duplication_clone_groups == 0
528 {
529 return AuditVerdict::Pass;
530 }
531 AuditVerdict::Pass
532}
533
534fn compute_programmatic_audit_attribution(
535 gate: AuditGate,
536 current: &AuditProgrammaticKeySnapshot,
537 base: Option<&AuditProgrammaticKeySnapshot>,
538) -> AuditAttribution {
539 let dead_code = count_introduced(¤t.dead_code, base.map(|snapshot| &snapshot.dead_code));
540 let complexity = count_introduced(¤t.health, base.map(|snapshot| &snapshot.health));
541 let duplication = count_introduced(¤t.dupes, base.map(|snapshot| &snapshot.dupes));
542 AuditAttribution {
543 gate,
544 dead_code_introduced: dead_code.0,
545 dead_code_inherited: dead_code.1,
546 complexity_introduced: complexity.0,
547 complexity_inherited: complexity.1,
548 duplication_introduced: duplication.0,
549 duplication_inherited: duplication.1,
550 }
551}
552
553fn count_introduced(
554 keys: &rustc_hash::FxHashSet<String>,
555 base: Option<&rustc_hash::FxHashSet<String>>,
556) -> (usize, usize) {
557 let Some(base) = base else {
558 return (0, 0);
559 };
560 keys.iter().fold((0, 0), |(introduced, inherited), key| {
561 if base.contains(key) {
562 (introduced, inherited + 1)
563 } else {
564 (introduced + 1, inherited)
565 }
566 })
567}
568
569fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditProgrammaticKeySnapshot {
570 AuditProgrammaticKeySnapshot {
571 dead_code: crate::audit_keys::dead_code_keys(
572 &analyses.dead_code.output.results,
573 &analyses.dead_code.root,
574 ),
575 health: crate::audit_keys::health_keys(
576 &analyses.complexity.report,
577 &analyses.complexity.root,
578 ),
579 dupes: analyses
580 .duplication
581 .output
582 .report
583 .clone_groups
584 .iter()
585 .map(|group| {
586 crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
587 })
588 .collect(),
589 }
590}
591
592fn compute_base_snapshot(
593 options: &AuditOptions,
594 base_ref: &str,
595) -> ProgrammaticResult<AuditProgrammaticKeySnapshot> {
596 let current_root = analysis_root_from_options(options)?;
597 let worktree = BaseWorktree::create(¤t_root, base_ref)?;
598 let base_root = base_analysis_root(¤t_root, worktree.path());
599 let current_config_path = options
600 .analysis
601 .config_path
602 .clone()
603 .or_else(|| fallow_config::FallowConfig::find_config_path(¤t_root));
604 let base_analysis = AnalysisOptions {
605 root: Some(base_root),
606 config_path: current_config_path,
607 changed_since: None,
608 explain: false,
609 ..options.analysis.clone()
610 };
611 let base = run_audit_subanalyses(options, &base_analysis, None)?;
612 Ok(snapshot_from_analyses(&base))
613}
614
615fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
616 match options.analysis.root.clone() {
617 Some(root) => Ok(root),
618 None => std::env::current_dir().map_err(|err| {
619 ProgrammaticError::new(
620 format!("failed to resolve current working directory: {err}"),
621 2,
622 )
623 .with_code("FALLOW_CWD_UNAVAILABLE")
624 .with_context("analysis.root")
625 }),
626 }
627}
628
629pub(super) struct BaseWorktree {
630 repo_root: PathBuf,
631 path: PathBuf,
632}
633
634impl BaseWorktree {
635 pub(super) fn create(repo_root: &Path, base_ref: &str) -> ProgrammaticResult<Self> {
636 let path = base_worktree_path()?;
637 let mut command = Command::new("git");
638 command
639 .args([
640 "worktree",
641 "add",
642 "--detach",
643 "--quiet",
644 path.to_string_lossy().as_ref(),
645 base_ref,
646 ])
647 .current_dir(repo_root);
648 clear_ambient_git_env(&mut command);
649 let output = command.output().map_err(|err| {
650 ProgrammaticError::new(
651 format!("could not create a temporary worktree for base ref `{base_ref}`: {err}"),
652 2,
653 )
654 .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
655 .with_context("audit.base")
656 })?;
657 if !output.status.success() {
658 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
659 return Err(ProgrammaticError::new(
660 format!(
661 "could not create a temporary worktree for base ref `{base_ref}`: {stderr}"
662 ),
663 2,
664 )
665 .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
666 .with_context("audit.base"));
667 }
668 Ok(Self {
669 repo_root: repo_root.to_path_buf(),
670 path,
671 })
672 }
673
674 pub(super) fn path(&self) -> &Path {
675 &self.path
676 }
677}
678
679impl Drop for BaseWorktree {
680 fn drop(&mut self) {
681 let mut command = Command::new("git");
682 command
683 .args([
684 "worktree",
685 "remove",
686 "--force",
687 self.path.to_string_lossy().as_ref(),
688 ])
689 .current_dir(&self.repo_root);
690 clear_ambient_git_env(&mut command);
691 let _ = command.output();
692 let _ = std::fs::remove_dir_all(&self.path);
693 }
694}
695
696fn base_worktree_path() -> ProgrammaticResult<PathBuf> {
697 let nanos = SystemTime::now()
698 .duration_since(SystemTime::UNIX_EPOCH)
699 .map_err(|err| {
700 ProgrammaticError::new(format!("system clock before unix epoch: {err}"), 2)
701 .with_code("FALLOW_CLOCK_ERROR")
702 .with_context("audit.base")
703 })?
704 .as_nanos();
705 Ok(std::env::temp_dir().join(format!(
706 "fallow-api-audit-base-{}-{nanos}",
707 std::process::id()
708 )))
709}
710
711pub(super) fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
712 let Some(git_root) = git_toplevel(current_root) else {
713 return base_worktree_root.to_path_buf();
714 };
715 let current_root =
716 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
717 match current_root.strip_prefix(&git_root) {
718 Ok(relative) => base_worktree_root.join(relative),
719 Err(_) => base_worktree_root.to_path_buf(),
720 }
721}
722
723fn git_toplevel(root: &Path) -> Option<PathBuf> {
724 git_stdout(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
725}
726
727fn audit_next_steps(
728 dead_code: &crate::DeadCodeProgrammaticOutput,
729 complexity: &crate::HealthProgrammaticOutput,
730) -> Vec<NextStep> {
731 let input = fallow_output::build_audit_next_steps_input(
732 Some((&dead_code.output.results, dead_code.root.as_path())),
733 Some(&complexity.report),
734 crate::next_steps::suggestions_enabled(),
735 );
736 build_audit_next_steps(&input)
737}
738
739fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
740 fallow_engine::validate::validate_git_ref(value)
741 .map(|_| ())
742 .map_err(|err| {
743 ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
744 .with_code("FALLOW_INVALID_GIT_REF")
745 .with_context(context)
746 })
747}
748
749fn audit_base_env_override() -> Option<String> {
750 std::env::var("FALLOW_AUDIT_BASE")
751 .ok()
752 .map(|value| value.trim().to_string())
753 .filter(|value| !value.is_empty())
754}
755
756fn auto_detect_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
757 if let Some(upstream) = git_upstream_ref(root) {
758 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
759 return Some(ResolvedAuditBase {
760 git_ref: sha,
761 description: Some(format!("merge-base with {upstream}")),
762 });
763 }
764 return Some(ResolvedAuditBase {
765 description: Some(format!("{upstream} (tip)")),
766 git_ref: upstream,
767 });
768 }
769
770 if let Some(remote_ref) = detect_remote_default_ref(root) {
771 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
772 return Some(ResolvedAuditBase {
773 git_ref: sha,
774 description: Some(format!("merge-base with {remote_ref}")),
775 });
776 }
777 return Some(ResolvedAuditBase {
778 description: Some(format!("{remote_ref} (tip)")),
779 git_ref: remote_ref,
780 });
781 }
782
783 for candidate in ["main", "master"] {
784 if git_ref_exists(root, candidate) {
785 return Some(ResolvedAuditBase {
786 git_ref: candidate.to_string(),
787 description: Some(format!("local {candidate}")),
788 });
789 }
790 }
791
792 None
793}
794
795fn git_stdout(root: &Path, args: &[&str]) -> Option<String> {
796 let mut command = Command::new("git");
797 command.args(args).current_dir(root);
798 clear_ambient_git_env(&mut command);
799 let output = command.output().ok()?;
800 if !output.status.success() {
801 return None;
802 }
803 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
804 (!trimmed.is_empty()).then_some(trimmed)
805}
806
807fn git_ref_exists(root: &Path, git_ref: &str) -> bool {
808 git_stdout(root, &["rev-parse", "--verify", "--quiet", git_ref]).is_some()
809}
810
811fn git_upstream_ref(root: &Path) -> Option<String> {
812 git_stdout(
813 root,
814 &[
815 "rev-parse",
816 "--abbrev-ref",
817 "--symbolic-full-name",
818 "@{upstream}",
819 ],
820 )
821}
822
823fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
824 git_stdout(root, &["merge-base", a, b])
825}
826
827fn detect_remote_default_ref(root: &Path) -> Option<String> {
828 if let Some(full_ref) = git_stdout(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
829 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
830 {
831 return Some(format!("origin/{branch}"));
832 }
833 ["origin/main", "origin/master"]
834 .into_iter()
835 .find(|candidate| git_ref_exists(root, candidate))
836 .map(str::to_string)
837}
838
839fn get_head_sha(root: &Path) -> Option<String> {
840 git_stdout(root, &["rev-parse", "--short", "HEAD"])
841}
842
843#[cfg(test)]
844mod tests {
845 use std::process::Command;
846
847 use fallow_config::{AuditGate, FallowConfig, HealthConfig};
848 use fallow_types::output_format::OutputFormat;
849
850 use super::*;
851
852 fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
853 FallowConfig {
854 health: HealthConfig {
855 max_crap,
856 ..HealthConfig::default()
857 },
858 ..FallowConfig::default()
859 }
860 .resolve(
861 std::env::temp_dir().join("fallow-api-runtime-test"),
862 OutputFormat::Json,
863 1,
864 true,
865 true,
866 None,
867 )
868 }
869
870 #[test]
871 fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
872 let options = ComplexityOptions {
873 complexity: true,
874 ..ComplexityOptions::default()
875 };
876 let config = resolved_config_with_max_crap(0.0);
877
878 assert!(!health_may_consume_dead_code_artifacts(&options, &config));
879 }
880
881 #[test]
882 fn audit_health_artifact_reuse_tracks_config_max_crap() {
883 let options = ComplexityOptions {
884 complexity: true,
885 ..ComplexityOptions::default()
886 };
887 let config = resolved_config_with_max_crap(30.0);
888
889 assert!(health_may_consume_dead_code_artifacts(&options, &config));
890 }
891
892 #[test]
893 fn audit_health_artifact_reuse_tracks_file_score_inputs() {
894 let config = resolved_config_with_max_crap(0.0);
895 for options in [
896 ComplexityOptions {
897 file_scores: true,
898 ..ComplexityOptions::default()
899 },
900 ComplexityOptions {
901 coverage_gaps: true,
902 ..ComplexityOptions::default()
903 },
904 ComplexityOptions {
905 targets: true,
906 ..ComplexityOptions::default()
907 },
908 ComplexityOptions {
909 score: true,
910 ..ComplexityOptions::default()
911 },
912 ComplexityOptions {
913 max_crap: Some(30.0),
914 complexity: true,
915 ..ComplexityOptions::default()
916 },
917 ] {
918 assert!(health_may_consume_dead_code_artifacts(&options, &config));
919 }
920 }
921
922 #[test]
923 fn audit_health_duplication_reuse_tracks_score_and_targets() {
924 for options in [
925 ComplexityOptions {
926 score: true,
927 ..ComplexityOptions::default()
928 },
929 ComplexityOptions {
930 targets: true,
931 ..ComplexityOptions::default()
932 },
933 ] {
934 assert!(health_may_consume_duplication_report(&options));
935 }
936
937 assert!(!health_may_consume_duplication_report(&ComplexityOptions {
938 complexity: true,
939 ..ComplexityOptions::default()
940 }));
941 }
942
943 #[test]
944 fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
945 let project = audit_fixture();
946 let output = run_audit(&AuditOptions {
947 analysis: AnalysisOptions {
948 root: Some(project.path().to_path_buf()),
949 no_cache: true,
950 explain: true,
951 ..AnalysisOptions::default()
952 },
953 base: Some("HEAD".to_string()),
954 gate: AuditGate::NewOnly,
955 ..AuditOptions::default()
956 })
957 .expect("audit output");
958
959 assert_eq!(output.verdict, AuditVerdict::Fail);
960 assert_eq!(output.summary.dead_code_issues, 1);
961 assert_eq!(output.attribution.dead_code_introduced, 1);
962 assert!(output.base_snapshot.is_some());
963
964 let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
965 assert_eq!(
966 json["dead_code"]["unused_files"][0]["path"],
967 "src/feature.ts"
968 );
969 assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
970 }
971
972 #[test]
973 fn empty_audit_output_uses_resolved_root_for_head_sha() {
974 let project = audit_fixture();
975 let output = empty_audit_output(
976 &AuditOptions {
977 analysis: AnalysisOptions {
978 root: None,
979 ..AnalysisOptions::default()
980 },
981 base: Some("HEAD".to_string()),
982 gate: AuditGate::NewOnly,
983 ..AuditOptions::default()
984 },
985 ResolvedAuditBase {
986 git_ref: "HEAD".to_string(),
987 description: None,
988 },
989 project.path(),
990 0,
991 std::time::Duration::ZERO,
992 );
993
994 assert!(output.head_sha.is_some());
995 }
996
997 fn audit_fixture() -> tempfile::TempDir {
998 let project = tempfile::tempdir().expect("project");
999 std::fs::create_dir_all(project.path().join("src")).expect("create src");
1000 std::fs::write(
1001 project.path().join("package.json"),
1002 r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
1003 )
1004 .expect("write package");
1005 std::fs::write(
1006 project.path().join("src/index.ts"),
1007 "console.log('entry');\n",
1008 )
1009 .expect("write entry");
1010 git(project.path(), &["init"]);
1011 git(project.path(), &["add", "."]);
1012 git(
1013 project.path(),
1014 &[
1015 "-c",
1016 "user.email=test@example.com",
1017 "-c",
1018 "user.name=Test",
1019 "-c",
1020 "commit.gpgsign=false",
1021 "commit",
1022 "-m",
1023 "initial",
1024 ],
1025 );
1026 std::fs::write(
1027 project.path().join("src/feature.ts"),
1028 "export const unused = 1;\n",
1029 )
1030 .expect("write changed source");
1031 project
1032 }
1033
1034 fn git(root: &Path, args: &[&str]) {
1035 let status = Command::new("git")
1036 .args(args)
1037 .current_dir(root)
1038 .status()
1039 .expect("git command");
1040 assert!(status.success(), "git {args:?} failed");
1041 }
1042}