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