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