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