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