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