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