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 comparison =
66 build_programmatic_audit_comparison(&head, &config, runtime_base_snapshot.as_ref());
67 let summary = build_programmatic_audit_summary(&head, &comparison);
68 let attribution =
69 comparison_attribution(options.gate, &comparison, runtime_base_snapshot.is_some());
70 let verdict = comparison_verdict(
71 options.gate,
72 &summary,
73 &head.duplication,
74 &head.complexity,
75 &config,
76 &comparison,
77 );
78 if runtime_base_snapshot.is_some() {
79 comparison.annotate_typed_findings(
80 &mut head.dead_code.output.results,
81 &mut head.complexity.report,
82 );
83 for (group, introduced) in head
84 .duplication
85 .output
86 .report
87 .clone_groups
88 .iter_mut()
89 .zip(comparison.dupes.introduced())
90 {
91 group.introduced = Some(AuditIntroduced(introduced));
92 }
93 }
94 let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
95 let base_snapshot = runtime_base_snapshot.map(|snapshot| snapshot.public);
96
97 Ok(AuditProgrammaticOutput {
98 verdict,
99 summary,
100 attribution,
101 changed_files_count,
102 base_ref: resolved_base.git_ref,
103 base_description: resolved_base.description,
104 head_sha: repo_refs::short_head_sha(resolved.root()),
105 elapsed: start.elapsed(),
106 base_snapshot_skipped: None,
107 base_snapshot,
108 dead_code: Some(head.dead_code),
109 duplication: Some(head.duplication),
110 complexity: Some(head.complexity),
111 next_steps,
112 envelope_mode: root_envelope_mode(),
113 telemetry_analysis_run_id: None,
114 })
115}
116
117fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
118 if let Err(err) =
119 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
120 {
121 return Err(ProgrammaticError::new(err, 2)
122 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
123 .with_context("audit.coverageRoot"));
124 }
125 if options.runtime_coverage.is_some() {
126 return Err(ProgrammaticError::new(
127 "programmatic audit does not yet support runtime coverage; use the CLI path",
128 2,
129 )
130 .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
131 .with_context("audit.runtimeCoverage"));
132 }
133 Ok(())
134}
135
136pub(super) fn resolve_audit_base_ref(
137 options: &AuditOptions,
138) -> ProgrammaticResult<ResolvedAuditBase> {
139 if let Some(ref_str) = options
140 .base
141 .as_deref()
142 .or(options.analysis.changed_since.as_deref())
143 {
144 validate_git_ref(ref_str, "audit.base")?;
145 return Ok(ResolvedAuditBase {
146 git_ref: (*ref_str).to_string(),
147 description: None,
148 });
149 }
150 if let Some(env_ref) = audit_base_env_override() {
151 validate_git_ref(&env_ref, "FALLOW_AUDIT_BASE")?;
152 return Ok(ResolvedAuditBase {
153 description: Some(format!("FALLOW_AUDIT_BASE={env_ref}")),
154 git_ref: env_ref,
155 });
156 }
157 let root = options
158 .analysis
159 .root
160 .clone()
161 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
162 repo_refs::auto_detect_audit_base_ref(&root).ok_or_else(|| {
163 ProgrammaticError::new(
164 "could not detect base branch. Set audit.base to specify the comparison target",
165 2,
166 )
167 .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
168 .with_context("audit.base")
169 })
170}
171
172fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
173 AnalysisOptions {
174 changed_since: Some(base_ref.to_string()),
175 production: options.production,
176 production_override: options.production.then_some(true),
177 ..options.analysis.clone()
178 }
179}
180
181fn analysis_with_production(
182 analysis: &AnalysisOptions,
183 production_override: Option<bool>,
184) -> AnalysisOptions {
185 AnalysisOptions {
186 production: production_override.unwrap_or(analysis.production),
187 production_override: production_override.or(analysis.production_override),
188 ..analysis.clone()
189 }
190}
191
192fn empty_audit_output(
193 options: &AuditOptions,
194 base: ResolvedAuditBase,
195 root: &Path,
196 changed_files_count: usize,
197 elapsed: std::time::Duration,
198) -> AuditProgrammaticOutput {
199 AuditProgrammaticOutput {
200 verdict: AuditVerdict::Pass,
201 summary: AuditSummary {
202 dead_code_issues: 0,
203 dead_code_has_errors: false,
204 complexity_findings: 0,
205 max_cyclomatic: None,
206 duplication_clone_groups: 0,
207 },
208 attribution: AuditAttribution {
209 gate: options.gate,
210 ..AuditAttribution::default()
211 },
212 changed_files_count,
213 base_ref: base.git_ref,
214 base_description: base.description,
215 head_sha: repo_refs::short_head_sha(root),
216 elapsed,
217 base_snapshot_skipped: None,
218 base_snapshot: None,
219 dead_code: None,
220 duplication: None,
221 complexity: None,
222 next_steps: Vec::new(),
223 envelope_mode: root_envelope_mode(),
224 telemetry_analysis_run_id: None,
225 }
226}
227
228struct AuditSubanalyses {
229 dead_code: crate::DeadCodeProgrammaticOutput,
230 duplication: crate::DuplicationProgrammaticOutput,
231 complexity: crate::HealthProgrammaticOutput,
232}
233
234struct AuditRuntimeKeySnapshot {
235 public: AuditProgrammaticKeySnapshot,
236 styling: FxHashSet<String>,
237}
238
239struct AuditSubanalysisOptions {
240 dead_code: DeadCodeOptions,
241 duplication: DuplicationOptions,
242 complexity: ComplexityOptions,
243}
244
245fn audit_subanalysis_options(
246 options: &AuditOptions,
247 analysis: &AnalysisOptions,
248) -> AuditSubanalysisOptions {
249 AuditSubanalysisOptions {
250 dead_code: DeadCodeOptions {
251 analysis: analysis_with_production(analysis, options.production_dead_code),
252 filters: DeadCodeFilters::default(),
253 files: Vec::new(),
254 include_entry_exports: options.include_entry_exports,
255 },
256 duplication: DuplicationOptions {
257 analysis: analysis_with_production(analysis, options.production_dupes),
258 ..DuplicationOptions::default()
259 },
260 complexity: ComplexityOptions {
261 analysis: analysis_with_production(analysis, options.production_health),
262 max_crap: options.max_crap,
263 complexity: true,
264 css: options.css.unwrap_or(true),
265 css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
266 coverage: options.coverage.clone(),
267 coverage_root: options.coverage_root.clone(),
268 ..ComplexityOptions::default()
269 },
270 }
271}
272
273fn run_audit_subanalyses(
274 options: &AuditOptions,
275 analysis: &AnalysisOptions,
276 changed_files: Option<&FxHashSet<PathBuf>>,
277) -> ProgrammaticResult<AuditSubanalyses> {
278 let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
279 run_audit_subanalyses_with_context(options, analysis, &resolved, changed_files)
280}
281
282fn run_audit_subanalyses_with_context(
283 options: &AuditOptions,
284 analysis: &AnalysisOptions,
285 resolved: &ProgrammaticAnalysisContext,
286 changed_files: Option<&FxHashSet<PathBuf>>,
287) -> ProgrammaticResult<AuditSubanalyses> {
288 let subanalysis_options = audit_subanalysis_options(options, analysis);
289 let production_modes = resolve_effective_production_modes(
290 resolved,
291 options.production_dead_code,
292 options.production_health,
293 options.production_dupes,
294 )?;
295
296 if production_modes.dead_code == production_modes.dupes
297 && production_modes.dead_code == production_modes.health
298 {
299 return run_shared_project_audit_subanalyses(&subanalysis_options, changed_files);
300 }
301
302 if production_modes.dead_code == production_modes.health {
303 return run_shared_dead_code_health_audit_subanalyses(&subanalysis_options, changed_files);
304 }
305
306 if production_modes.dead_code == production_modes.dupes {
307 return run_shared_dead_code_dupes_audit_subanalyses(&subanalysis_options, changed_files);
308 }
309
310 Ok(AuditSubanalyses {
311 dead_code: run_dead_code(&subanalysis_options.dead_code)?,
312 duplication: run_duplication(&subanalysis_options.duplication)?,
313 complexity: run_health(&subanalysis_options.complexity)?,
314 })
315}
316
317fn run_shared_project_audit_subanalyses(
318 options: &AuditSubanalysisOptions,
319 changed_files: Option<&FxHashSet<PathBuf>>,
320) -> ProgrammaticResult<AuditSubanalyses> {
321 let resolved =
322 resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
323 resolved.install(|| {
324 let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
325 run_all_audit_subanalyses_with_project_artifacts(
326 &options.dead_code,
327 &options.duplication,
328 &options.complexity,
329 &resolved,
330 &session,
331 changed_files,
332 )
333 })
334}
335
336fn run_shared_dead_code_health_audit_subanalyses(
337 options: &AuditSubanalysisOptions,
338 changed_files: Option<&FxHashSet<PathBuf>>,
339) -> ProgrammaticResult<AuditSubanalyses> {
340 let resolved =
341 resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
342 resolved.install(|| {
343 let dead_code_options = &options.dead_code;
344 let duplication_options = &options.duplication;
345 let complexity_options = &options.complexity;
346 let session = super::dead_code::load_dead_code_session(dead_code_options, &resolved)?;
347 let (dead_code, complexity) = run_dead_code_and_health_with_session(
348 dead_code_options,
349 complexity_options,
350 &resolved,
351 &session,
352 changed_files,
353 )?;
354 Ok(AuditSubanalyses {
355 dead_code,
356 duplication: run_duplication(duplication_options)?,
357 complexity,
358 })
359 })
360}
361
362fn run_shared_dead_code_dupes_audit_subanalyses(
363 options: &AuditSubanalysisOptions,
364 changed_files: Option<&FxHashSet<PathBuf>>,
365) -> ProgrammaticResult<AuditSubanalyses> {
366 let resolved =
367 resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
368 resolved.install(|| {
369 let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
370 let (dead_code, duplication, _, _) =
371 run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
372 dead_code_options: &options.dead_code,
373 duplication_options: &options.duplication,
374 resolved: &resolved,
375 session: &session,
376 changed_files,
377 retain_dead_code_artifacts: false,
378 retain_duplication_artifacts: false,
379 })?;
380 Ok(AuditSubanalyses {
381 dead_code,
382 duplication,
383 complexity: run_health(&options.complexity)?,
384 })
385 })
386}
387
388fn run_dead_code_and_duplication_with_project_artifacts(
389 input: ProjectArtifactAuditInput<'_>,
390) -> ProgrammaticResult<(
391 crate::DeadCodeProgrammaticOutput,
392 crate::DuplicationProgrammaticOutput,
393 Option<DeadCodeAnalysisArtifacts>,
394 Option<fallow_engine::duplicates::DuplicationReport>,
395)> {
396 let dupes_config = super::duplication::build_dupes_config(
397 input.duplication_options,
398 &input.session.config().duplicates,
399 );
400 let section_start = Instant::now();
401 let project = input
402 .session
403 .analyze_project_with_artifacts(
404 &dupes_config,
405 ProjectAnalysisArtifactOptions {
406 retain_complexity_artifacts: input.retain_dead_code_artifacts,
407 retain_graph: input.retain_dead_code_artifacts,
408 changed_files: input.changed_files.cloned(),
409 collect_source_fingerprints: false,
410 },
411 )
412 .map_err(|err| {
413 ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
414 .with_code("FALLOW_AUDIT_FAILED")
415 .with_context("audit")
416 })?;
417 let duplication_artifacts = input
418 .retain_duplication_artifacts
419 .then(|| project.duplication.clone());
420 let dead_code = super::dead_code::run_dead_code_from_artifacts(
421 input.dead_code_options,
422 input.resolved,
423 input.session,
424 input.changed_files,
425 project.dead_code,
426 section_start,
427 )?;
428 let duplication = super::duplication::run_duplication_report_with_session(
429 input.duplication_options,
430 input.resolved,
431 input.session,
432 project.duplication,
433 section_start,
434 )?;
435 let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
436 output: dead_code,
437 artifacts,
438 } = dead_code;
439 let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
440 Ok((
441 dead_code,
442 duplication,
443 dead_code_artifacts,
444 duplication_artifacts,
445 ))
446}
447
448#[derive(Clone, Copy)]
449struct ProjectArtifactAuditInput<'a> {
450 dead_code_options: &'a DeadCodeOptions,
451 duplication_options: &'a DuplicationOptions,
452 resolved: &'a ProgrammaticAnalysisContext,
453 session: &'a AnalysisSession,
454 changed_files: Option<&'a FxHashSet<PathBuf>>,
455 retain_dead_code_artifacts: bool,
456 retain_duplication_artifacts: bool,
457}
458
459fn run_all_audit_subanalyses_with_project_artifacts(
460 dead_code_options: &DeadCodeOptions,
461 duplication_options: &DuplicationOptions,
462 complexity_options: &ComplexityOptions,
463 resolved: &ProgrammaticAnalysisContext,
464 session: &AnalysisSession,
465 changed_files: Option<&FxHashSet<PathBuf>>,
466) -> ProgrammaticResult<AuditSubanalyses> {
467 let retain_dead_code_artifacts =
468 health_may_consume_dead_code_artifacts(complexity_options, session.config());
469 let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
470 let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
471 run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
472 dead_code_options,
473 duplication_options,
474 resolved,
475 session,
476 changed_files,
477 retain_dead_code_artifacts,
478 retain_duplication_artifacts,
479 })?;
480 let complexity = run_health_with_session_artifacts(
481 complexity_options,
482 resolved,
483 session,
484 changed_files,
485 dead_code_artifacts,
486 duplication_artifacts,
487 )?;
488 Ok(AuditSubanalyses {
489 dead_code,
490 duplication,
491 complexity,
492 })
493}
494
495fn run_dead_code_and_health_with_session(
496 dead_code_options: &DeadCodeOptions,
497 complexity_options: &ComplexityOptions,
498 resolved: &ProgrammaticAnalysisContext,
499 session: &AnalysisSession,
500 changed_files: Option<&FxHashSet<PathBuf>>,
501) -> ProgrammaticResult<(
502 crate::DeadCodeProgrammaticOutput,
503 crate::HealthProgrammaticOutput,
504)> {
505 let reuse_dead_code_artifacts =
506 health_may_consume_dead_code_artifacts(complexity_options, session.config());
507 let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
508 let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
509 dead_code_options,
510 resolved,
511 session,
512 changed_files,
513 |_| {},
514 Instant::now(),
515 )?;
516 (dead_code.output, Some(dead_code.artifacts))
517 } else {
518 (
519 super::dead_code::run_dead_code_with_session(
520 dead_code_options,
521 resolved,
522 session,
523 changed_files,
524 |_| {},
525 Instant::now(),
526 )?,
527 None,
528 )
529 };
530 let complexity = run_health_with_session_artifacts(
531 complexity_options,
532 resolved,
533 session,
534 changed_files,
535 dead_code_artifacts,
536 None,
537 )?;
538 Ok((dead_code, complexity))
539}
540
541fn load_programmatic_audit_config(
542 resolved: &ProgrammaticAnalysisContext,
543) -> ProgrammaticResult<fallow_config::ResolvedConfig> {
544 fallow_engine::project_config::config_for_project_analysis(
545 resolved.root(),
546 resolved.config_path().as_deref(),
547 ProjectConfigOptions {
548 output: OutputFormat::Json,
549 no_cache: resolved.no_cache(),
550 threads: resolved.threads(),
551 production_override: resolved.production_override(),
552 quiet: true,
553 analysis: ProductionAnalysis::DeadCode,
554 allow_remote_extends: resolved.allow_remote_extends(),
555 },
556 )
557 .map(|project| project.config)
558 .map_err(|err| {
559 ProgrammaticError::new(format!("failed to load config: {err}"), 2)
560 .with_code("FALLOW_CONFIG_LOAD_FAILED")
561 .with_context("analysis.configPath")
562 })
563}
564
565fn build_programmatic_audit_comparison(
566 analyses: &AuditSubanalyses,
567 config: &fallow_config::ResolvedConfig,
568 base: Option<&AuditRuntimeKeySnapshot>,
569) -> crate::audit_keys::AuditComparison {
570 let dupe_keys = analyses
571 .duplication
572 .output
573 .report
574 .clone_groups
575 .iter()
576 .map(|group| crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root))
577 .collect();
578 let styling_keys = analyses
579 .complexity
580 .report
581 .styling_findings
582 .iter()
583 .map(|finding| crate::audit_keys::styling_finding_key(finding, &analyses.complexity.root))
584 .collect();
585 crate::audit_keys::AuditComparison::build(crate::audit_keys::AuditComparisonInput {
586 results: &analyses.dead_code.output.results,
587 config,
588 root: &analyses.dead_code.root,
589 health: &analyses.complexity.report,
590 health_root: &analyses.complexity.root,
591 dupe_keys,
592 styling_keys,
593 base_dead_code: base.map(|snapshot| &snapshot.public.dead_code),
594 base_health: base.map(|snapshot| &snapshot.public.health),
595 base_dupes: base.map(|snapshot| &snapshot.public.dupes),
596 base_styling: base.map(|snapshot| &snapshot.styling),
597 })
598}
599
600fn build_programmatic_audit_summary(
601 analyses: &AuditSubanalyses,
602 comparison: &crate::audit_keys::AuditComparison,
603) -> AuditSummary {
604 let dead_code_issues = comparison.dead_code.visible_count();
605 AuditSummary {
606 dead_code_issues,
607 dead_code_has_errors: comparison.dead_code.has_errors(),
608 complexity_findings: analyses.complexity.report.findings.len(),
609 max_cyclomatic: analyses
610 .complexity
611 .report
612 .findings
613 .iter()
614 .map(|finding| finding.cyclomatic)
615 .max(),
616 duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
617 }
618}
619
620fn styling_finding_gates(rules: &fallow_config::RulesConfig, code: &str) -> bool {
621 let severity = match code {
622 "css-token-drift" => rules.css_token_drift,
623 "css-duplicate-block" => rules.css_duplicate_block,
624 "css-selector-complexity" => rules.css_selector_complexity,
625 "css-dead-surface" => rules.css_dead_surface,
626 "css-broken-reference" => rules.css_broken_reference,
627 _ => fallow_config::Severity::Warn,
628 };
629 severity == fallow_config::Severity::Error
630}
631
632fn comparison_verdict(
633 gate: AuditGate,
634 summary: &AuditSummary,
635 duplication: &crate::DuplicationProgrammaticOutput,
636 complexity: &crate::HealthProgrammaticOutput,
637 config: &fallow_config::ResolvedConfig,
638 comparison: &crate::audit_keys::AuditComparison,
639) -> AuditVerdict {
640 let new_only = matches!(gate, AuditGate::NewOnly);
641 let dead_code_errors = if new_only {
642 comparison.dead_code.has_introduced_errors()
643 } else {
644 comparison.dead_code.has_errors()
645 };
646 let dead_code_warnings = if new_only {
647 comparison.dead_code.has_introduced_warnings()
648 } else {
649 comparison
650 .dead_code
651 .records()
652 .iter()
653 .any(|record| record.effective_severity == fallow_config::Severity::Warn)
654 };
655 let complexity_findings = if new_only {
656 comparison.health.introduced_count()
657 } else {
658 summary.complexity_findings
659 };
660 let styling_errors = complexity
661 .report
662 .styling_findings
663 .iter()
664 .zip(comparison.styling.introduced())
665 .any(|(finding, introduced)| {
666 (!new_only || introduced) && styling_finding_gates(&config.rules, &finding.code)
667 });
668 if dead_code_errors || complexity_findings > 0 || styling_errors {
669 return AuditVerdict::Fail;
670 }
671 let duplication_findings = if new_only {
672 comparison.dupes.introduced_count()
673 } else {
674 summary.duplication_clone_groups
675 };
676 if duplication_findings > 0 {
677 let pct = duplication.output.report.stats.duplication_percentage;
678 if duplication.threshold > 0.0 && pct > duplication.threshold {
679 return AuditVerdict::Fail;
680 }
681 return AuditVerdict::Warn;
682 }
683 if dead_code_warnings {
684 return AuditVerdict::Warn;
685 }
686 AuditVerdict::Pass
687}
688
689fn comparison_attribution(
690 gate: AuditGate,
691 comparison: &crate::audit_keys::AuditComparison,
692 has_base: bool,
693) -> AuditAttribution {
694 if !has_base {
695 return AuditAttribution {
696 gate,
697 ..AuditAttribution::default()
698 };
699 }
700 AuditAttribution {
701 gate,
702 dead_code_introduced: comparison.dead_code.introduced_count(),
703 dead_code_inherited: comparison.dead_code.inherited_count(),
704 complexity_introduced: comparison.health.introduced_count(),
705 complexity_inherited: comparison.health.inherited_count(),
706 duplication_introduced: comparison.dupes.introduced_count(),
707 duplication_inherited: comparison.dupes.inherited_count(),
708 }
709}
710
711fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditRuntimeKeySnapshot {
712 let styling =
713 crate::audit_keys::styling_keys(&analyses.complexity.report, &analyses.complexity.root);
714 let mut health =
715 crate::audit_keys::health_keys(&analyses.complexity.report, &analyses.complexity.root);
716 health.extend(styling.iter().cloned());
717 AuditRuntimeKeySnapshot {
718 public: AuditProgrammaticKeySnapshot {
719 dead_code: crate::audit_keys::dead_code_keys(
720 &analyses.dead_code.output.results,
721 &analyses.dead_code.root,
722 ),
723 health,
724 dupes: analyses
725 .duplication
726 .output
727 .report
728 .clone_groups
729 .iter()
730 .map(|group| {
731 crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
732 })
733 .collect(),
734 },
735 styling,
736 }
737}
738
739fn compute_base_snapshot(
740 options: &AuditOptions,
741 base_ref: &str,
742) -> ProgrammaticResult<AuditRuntimeKeySnapshot> {
743 let current_root = analysis_root_from_options(options)?;
744 let worktree = TemporaryBaseWorktree::create(¤t_root, base_ref).map_err(|err| {
745 ProgrammaticError::new(err.to_string(), 2)
746 .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
747 .with_context("audit.base")
748 })?;
749 let base_root = repo_refs::base_analysis_root(¤t_root, worktree.path());
750 let current_config_path = options
751 .analysis
752 .config_path
753 .clone()
754 .or_else(|| fallow_config::FallowConfig::find_config_path(¤t_root));
755 let base_analysis = AnalysisOptions {
756 root: Some(base_root),
757 config_path: current_config_path,
758 changed_since: None,
759 explain: false,
760 ..options.analysis.clone()
761 };
762 let base = run_audit_subanalyses(options, &base_analysis, None)?;
763 Ok(snapshot_from_analyses(&base))
764}
765
766fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
767 match options.analysis.root.clone() {
768 Some(root) => Ok(root),
769 None => std::env::current_dir().map_err(|err| {
770 ProgrammaticError::new(
771 format!("failed to resolve current working directory: {err}"),
772 2,
773 )
774 .with_code("FALLOW_CWD_UNAVAILABLE")
775 .with_context("analysis.root")
776 }),
777 }
778}
779
780fn audit_next_steps(
781 dead_code: &crate::DeadCodeProgrammaticOutput,
782 complexity: &crate::HealthProgrammaticOutput,
783) -> Vec<NextStep> {
784 let input = fallow_output::build_audit_next_steps_input(
785 Some((&dead_code.output.results, dead_code.root.as_path())),
786 Some(&complexity.report),
787 crate::next_steps::suggestions_enabled(),
788 );
789 build_audit_next_steps(&input)
790}
791
792fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
793 fallow_engine::validate::validate_git_ref(value)
794 .map(|_| ())
795 .map_err(|err| {
796 ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
797 .with_code("FALLOW_INVALID_GIT_REF")
798 .with_context(context)
799 })
800}
801
802fn audit_base_env_override() -> Option<String> {
803 std::env::var("FALLOW_AUDIT_BASE")
804 .ok()
805 .map(|value| value.trim().to_string())
806 .filter(|value| !value.is_empty())
807}
808
809#[cfg(test)]
810mod tests {
811 use std::process::Command;
812
813 use fallow_config::{AuditGate, FallowConfig, HealthConfig};
814 use fallow_types::output_format::OutputFormat;
815
816 use super::*;
817
818 fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
819 FallowConfig {
820 health: HealthConfig {
821 max_crap,
822 ..HealthConfig::default()
823 },
824 ..FallowConfig::default()
825 }
826 .resolve(
827 std::env::temp_dir().join("fallow-api-runtime-test"),
828 OutputFormat::Json,
829 1,
830 true,
831 true,
832 None,
833 )
834 }
835
836 #[test]
837 fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
838 let options = ComplexityOptions {
839 complexity: true,
840 ..ComplexityOptions::default()
841 };
842 let config = resolved_config_with_max_crap(0.0);
843
844 assert!(!health_may_consume_dead_code_artifacts(&options, &config));
845 }
846
847 #[test]
848 fn audit_health_artifact_reuse_tracks_config_max_crap() {
849 let options = ComplexityOptions {
850 complexity: true,
851 ..ComplexityOptions::default()
852 };
853 let config = resolved_config_with_max_crap(30.0);
854
855 assert!(health_may_consume_dead_code_artifacts(&options, &config));
856 }
857
858 #[test]
859 fn audit_health_artifact_reuse_tracks_file_score_inputs() {
860 let config = resolved_config_with_max_crap(0.0);
861 for options in [
862 ComplexityOptions {
863 file_scores: true,
864 ..ComplexityOptions::default()
865 },
866 ComplexityOptions {
867 coverage_gaps: true,
868 ..ComplexityOptions::default()
869 },
870 ComplexityOptions {
871 targets: true,
872 ..ComplexityOptions::default()
873 },
874 ComplexityOptions {
875 score: true,
876 ..ComplexityOptions::default()
877 },
878 ComplexityOptions {
879 max_crap: Some(30.0),
880 complexity: true,
881 ..ComplexityOptions::default()
882 },
883 ] {
884 assert!(health_may_consume_dead_code_artifacts(&options, &config));
885 }
886 }
887
888 #[test]
889 fn audit_health_duplication_reuse_tracks_score_and_targets() {
890 for options in [
891 ComplexityOptions {
892 score: true,
893 ..ComplexityOptions::default()
894 },
895 ComplexityOptions {
896 targets: true,
897 ..ComplexityOptions::default()
898 },
899 ] {
900 assert!(health_may_consume_duplication_report(&options));
901 }
902
903 assert!(!health_may_consume_duplication_report(&ComplexityOptions {
904 complexity: true,
905 ..ComplexityOptions::default()
906 }));
907 }
908
909 #[test]
910 fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
911 let project = audit_fixture();
912 let output = run_audit(&AuditOptions {
913 analysis: AnalysisOptions {
914 root: Some(project.path().to_path_buf()),
915 no_cache: true,
916 explain: true,
917 ..AnalysisOptions::default()
918 },
919 base: Some("HEAD".to_string()),
920 gate: AuditGate::NewOnly,
921 ..AuditOptions::default()
922 })
923 .expect("audit output");
924
925 assert_eq!(output.verdict, AuditVerdict::Fail);
926 assert_eq!(output.summary.dead_code_issues, 1);
927 assert_eq!(output.attribution.dead_code_introduced, 1);
928 assert!(output.base_snapshot.is_some());
929
930 let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
931 assert_eq!(
932 json["dead_code"]["unused_files"][0]["path"],
933 "src/feature.ts"
934 );
935 assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
936 }
937
938 #[test]
939 fn run_audit_warn_only_dead_code_matches_cli_verdict_semantics() {
940 let project = audit_fixture();
941 std::fs::write(
942 project.path().join(".fallowrc.json"),
943 r#"{"rules":{"unused-files":"warn"}}"#,
944 )
945 .expect("write config");
946
947 let output = run_audit(&AuditOptions {
948 analysis: AnalysisOptions {
949 root: Some(project.path().to_path_buf()),
950 no_cache: true,
951 ..AnalysisOptions::default()
952 },
953 base: Some("HEAD".to_string()),
954 gate: AuditGate::All,
955 ..AuditOptions::default()
956 })
957 .expect("audit output");
958
959 assert_eq!(output.verdict, AuditVerdict::Warn);
960 assert!(!output.summary.dead_code_has_errors);
961 }
962
963 #[test]
964 fn run_audit_styling_error_matches_cli_for_new_only_and_all_gates() {
965 let project = audit_styling_fixture();
966 let root = project.path();
967 std::fs::write(
968 root.join("src/styles.css"),
969 "#app .legacy .title { color: red; }\n.plain { color: blue; }\n",
970 )
971 .expect("write inherited-only change");
972
973 let all = run_audit(&AuditOptions {
974 analysis: AnalysisOptions {
975 root: Some(root.to_path_buf()),
976 no_cache: true,
977 ..AnalysisOptions::default()
978 },
979 base: Some("HEAD".to_string()),
980 gate: AuditGate::All,
981 ..AuditOptions::default()
982 })
983 .expect("all-gate audit");
984 assert_eq!(all.verdict, AuditVerdict::Fail);
985
986 let inherited_only = run_audit(&AuditOptions {
987 analysis: AnalysisOptions {
988 root: Some(root.to_path_buf()),
989 no_cache: true,
990 ..AnalysisOptions::default()
991 },
992 base: Some("HEAD".to_string()),
993 gate: AuditGate::NewOnly,
994 ..AuditOptions::default()
995 })
996 .expect("new-only inherited audit");
997 assert_eq!(inherited_only.verdict, AuditVerdict::Pass);
998 assert!(inherited_only.base_snapshot.is_some());
999 let inherited_json =
1000 crate::serialize_audit_programmatic_json(inherited_only).expect("inherited audit JSON");
1001 assert_eq!(
1002 inherited_json["complexity"]["styling_findings"][0]["introduced"],
1003 false
1004 );
1005
1006 std::fs::write(
1007 root.join("src/styles.css"),
1008 "#app .legacy .title { color: red; }\n.plain { color: blue; }\n#app .introduced .title { color: green; }\n",
1009 )
1010 .expect("write introduced styling change");
1011 let introduced = run_audit(&AuditOptions {
1012 analysis: AnalysisOptions {
1013 root: Some(root.to_path_buf()),
1014 no_cache: true,
1015 ..AnalysisOptions::default()
1016 },
1017 base: Some("HEAD".to_string()),
1018 gate: AuditGate::NewOnly,
1019 ..AuditOptions::default()
1020 })
1021 .expect("new-only introduced audit");
1022 assert_eq!(introduced.verdict, AuditVerdict::Fail);
1023 let introduced_json =
1024 crate::serialize_audit_programmatic_json(introduced).expect("introduced audit JSON");
1025 let styling = introduced_json["complexity"]["styling_findings"]
1026 .as_array()
1027 .expect("styling findings");
1028 assert!(
1029 styling
1030 .iter()
1031 .any(|finding| finding["line"] == 1 && finding["introduced"] == false)
1032 );
1033 assert!(
1034 styling
1035 .iter()
1036 .any(|finding| finding["line"] == 3 && finding["introduced"] == true)
1037 );
1038 }
1039
1040 #[test]
1041 fn audit_production_mode_branches_preserve_per_section_workspace_scope() {
1042 let project = audit_workspace_modes_fixture();
1043
1044 for mask in 0_u8..8 {
1045 let production_dead_code = mask & 0b001 != 0;
1046 let production_health = mask & 0b010 != 0;
1047 let production_dupes = mask & 0b100 != 0;
1048 let output = run_audit(&AuditOptions {
1049 analysis: AnalysisOptions {
1050 root: Some(project.path().to_path_buf()),
1051 workspace: Some(vec!["@audit/a".to_string()]),
1052 no_cache: true,
1053 ..AnalysisOptions::default()
1054 },
1055 base: Some("HEAD".to_string()),
1056 gate: AuditGate::All,
1057 production_dead_code: Some(production_dead_code),
1058 production_health: Some(production_health),
1059 production_dupes: Some(production_dupes),
1060 include_entry_exports: true,
1061 ..AuditOptions::default()
1062 })
1063 .unwrap_or_else(|error| panic!("audit mask {mask:03b} failed: {error}"));
1064 let json = crate::serialize_audit_programmatic_json(output)
1065 .unwrap_or_else(|error| panic!("serialize mask {mask:03b}: {error}"));
1066
1067 let dead_code = json["dead_code"].to_string();
1068 let complexity = json["complexity"].to_string();
1069 let duplication = json["duplication"].to_string();
1070 assert_eq!(
1071 dead_code.contains("mode-sentinel.test.ts"),
1072 !production_dead_code,
1073 "dead-code scope mismatch for mask {mask:03b}: {dead_code}"
1074 );
1075 assert_eq!(
1076 complexity.contains("mode-sentinel.test.ts"),
1077 !production_health,
1078 "health scope mismatch for mask {mask:03b}: {complexity}"
1079 );
1080 assert_eq!(
1081 duplication.contains("mode-sentinel.test.ts"),
1082 !production_dupes,
1083 "duplication scope mismatch for mask {mask:03b}: {duplication}"
1084 );
1085
1086 let rendered = json.to_string();
1087 assert!(
1088 !rendered.contains("packages/b"),
1089 "workspace B leaked into mask {mask:03b}: {rendered}"
1090 );
1091 }
1092 }
1093
1094 #[test]
1095 fn empty_audit_output_uses_resolved_root_for_head_sha() {
1096 let project = audit_fixture();
1097 let output = empty_audit_output(
1098 &AuditOptions {
1099 analysis: AnalysisOptions {
1100 root: None,
1101 ..AnalysisOptions::default()
1102 },
1103 base: Some("HEAD".to_string()),
1104 gate: AuditGate::NewOnly,
1105 ..AuditOptions::default()
1106 },
1107 ResolvedAuditBase {
1108 git_ref: "HEAD".to_string(),
1109 description: None,
1110 },
1111 project.path(),
1112 0,
1113 std::time::Duration::ZERO,
1114 );
1115
1116 assert!(output.head_sha.is_some());
1117 }
1118
1119 fn audit_fixture() -> tempfile::TempDir {
1120 let project = tempfile::tempdir().expect("project");
1121 std::fs::create_dir_all(project.path().join("src")).expect("create src");
1122 std::fs::write(
1123 project.path().join("package.json"),
1124 r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
1125 )
1126 .expect("write package");
1127 std::fs::write(
1128 project.path().join("src/index.ts"),
1129 "console.log('entry');\n",
1130 )
1131 .expect("write entry");
1132 git(project.path(), &["init"]);
1133 git(project.path(), &["add", "."]);
1134 git(
1135 project.path(),
1136 &[
1137 "-c",
1138 "user.email=test@example.com",
1139 "-c",
1140 "user.name=Test",
1141 "-c",
1142 "commit.gpgsign=false",
1143 "commit",
1144 "-m",
1145 "initial",
1146 ],
1147 );
1148 std::fs::write(
1149 project.path().join("src/feature.ts"),
1150 "export const unused = 1;\n",
1151 )
1152 .expect("write changed source");
1153 project
1154 }
1155
1156 fn audit_styling_fixture() -> tempfile::TempDir {
1157 let project = tempfile::tempdir().expect("project");
1158 std::fs::create_dir_all(project.path().join("src")).expect("create src");
1159 std::fs::write(
1160 project.path().join("package.json"),
1161 r#"{"name":"audit-api-styling","type":"module","main":"src/index.ts"}"#,
1162 )
1163 .expect("write package");
1164 std::fs::write(
1165 project.path().join(".fallowrc.json"),
1166 r#"{"rules":{"css-selector-complexity":"error"}}"#,
1167 )
1168 .expect("write config");
1169 std::fs::write(
1170 project.path().join("src/index.ts"),
1171 "console.log('entry');\n",
1172 )
1173 .expect("write entry");
1174 std::fs::write(
1175 project.path().join("src/styles.css"),
1176 "#app .legacy .title { color: red; }\n",
1177 )
1178 .expect("write inherited styling");
1179 git(project.path(), &["init"]);
1180 git(project.path(), &["add", "."]);
1181 git(
1182 project.path(),
1183 &[
1184 "-c",
1185 "user.email=test@example.com",
1186 "-c",
1187 "user.name=Test",
1188 "-c",
1189 "commit.gpgsign=false",
1190 "commit",
1191 "-m",
1192 "initial",
1193 ],
1194 );
1195 project
1196 }
1197
1198 fn audit_workspace_modes_fixture() -> tempfile::TempDir {
1199 let project = tempfile::tempdir().expect("project");
1200 std::fs::write(
1201 project.path().join("package.json"),
1202 r#"{"name":"audit-root","private":true,"workspaces":["packages/*"]}"#,
1203 )
1204 .expect("write root package");
1205 std::fs::write(
1206 project.path().join(".fallowrc.json"),
1207 r#"{
1208 "duplicates": {
1209 "minTokens": 10,
1210 "minLines": 2,
1211 "ignoreDefaults": false
1212 },
1213 "health": {
1214 "maxCyclomatic": 2,
1215 "maxCognitive": 2,
1216 "maxCrap": 2.0,
1217 "maxUnitSize": 3
1218 }
1219}"#,
1220 )
1221 .expect("write config");
1222
1223 for name in ["a", "b"] {
1224 let package = project.path().join("packages").join(name);
1225 std::fs::create_dir_all(package.join("src")).expect("create package source");
1226 std::fs::write(
1227 package.join("package.json"),
1228 format!(r#"{{"name":"@audit/{name}","type":"module","main":"src/index.ts"}}"#),
1229 )
1230 .expect("write package manifest");
1231 std::fs::write(
1232 package.join("src/index.ts"),
1233 format!("export const {name}Entry = true;\n"),
1234 )
1235 .expect("write package entry");
1236 }
1237
1238 git(project.path(), &["init"]);
1239 git(project.path(), &["add", "."]);
1240 git(
1241 project.path(),
1242 &[
1243 "-c",
1244 "user.email=test@example.com",
1245 "-c",
1246 "user.name=Test",
1247 "-c",
1248 "commit.gpgsign=false",
1249 "commit",
1250 "-m",
1251 "initial",
1252 ],
1253 );
1254
1255 let sentinel = r"export function auditModeSentinel(value: number) {
1256 let result = value;
1257 if (value > 0) result += 1;
1258 if (value > 1) result += 2;
1259 if (value > 2) result += 3;
1260 if (value > 3) result += 4;
1261 return result;
1262}
1263";
1264 for name in ["a", "b"] {
1265 let source = project.path().join("packages").join(name).join("src");
1266 std::fs::write(source.join("mode-sentinel.test.ts"), sentinel)
1267 .expect("write test sentinel");
1268 std::fs::write(source.join("mode-sentinel-copy.test.ts"), sentinel)
1269 .expect("write duplicate test sentinel");
1270 }
1271
1272 project
1273 }
1274
1275 fn git(root: &Path, args: &[&str]) {
1276 let status = Command::new("git")
1277 .args(args)
1278 .current_dir(root)
1279 .status()
1280 .expect("git command");
1281 assert!(status.success(), "git {args:?} failed");
1282 }
1283}