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