1use std::path::{Path, PathBuf};
2
3use fallow_config::{EmailMode, OutputFormat};
4use fallow_core::results::AnalysisResults;
5use serde::Serialize;
6
7use crate::check::{CheckOptions, IssueFilters, TraceOptions};
8use crate::dupes::{DupesMode, DupesOptions};
9use crate::health::{HealthOptions, SortBy};
10use crate::health_types::EffortEstimate;
11use crate::report::ci::diff_filter::{DiffIndex, LoadedDiff, MAX_DIFF_BYTES};
12use crate::report::{build_duplication_json, build_health_json};
13
14pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
15 "root",
16 "config",
17 "no-cache",
18 "threads",
19 "changed-since",
20 "diff-file",
21 "production",
22 "workspace",
23 "changed-workspaces",
24 "explain",
25];
26
27#[derive(Debug, Clone, Serialize)]
29pub struct ProgrammaticError {
30 pub message: String,
31 pub exit_code: u8,
32 pub code: Option<String>,
33 pub help: Option<String>,
34 pub context: Option<String>,
35}
36
37impl ProgrammaticError {
38 #[must_use]
39 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
40 Self {
41 message: message.into(),
42 exit_code,
43 code: None,
44 help: None,
45 context: None,
46 }
47 }
48
49 #[must_use]
50 pub fn with_help(mut self, help: impl Into<String>) -> Self {
51 self.help = Some(help.into());
52 self
53 }
54
55 #[must_use]
56 pub fn with_code(mut self, code: impl Into<String>) -> Self {
57 self.code = Some(code.into());
58 self
59 }
60
61 #[must_use]
62 pub fn with_context(mut self, context: impl Into<String>) -> Self {
63 self.context = Some(context.into());
64 self
65 }
66}
67
68impl std::fmt::Display for ProgrammaticError {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 write!(f, "{}", self.message)
71 }
72}
73
74impl std::error::Error for ProgrammaticError {}
75
76type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
77
78#[derive(Debug, Clone, Default)]
80pub struct AnalysisOptions {
81 pub root: Option<PathBuf>,
82 pub config_path: Option<PathBuf>,
83 pub no_cache: bool,
84 pub threads: Option<usize>,
85 pub diff_file: Option<PathBuf>,
86 pub production: bool,
89 pub production_override: Option<bool>,
92 pub changed_since: Option<String>,
93 pub workspace: Option<Vec<String>>,
94 pub changed_workspaces: Option<String>,
95 pub explain: bool,
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct DeadCodeFilters {
101 pub unused_files: bool,
102 pub unused_exports: bool,
103 pub unused_deps: bool,
104 pub unused_types: bool,
105 pub private_type_leaks: bool,
106 pub unused_enum_members: bool,
107 pub unused_class_members: bool,
108 pub unresolved_imports: bool,
109 pub unlisted_deps: bool,
110 pub duplicate_exports: bool,
111 pub circular_deps: bool,
112 pub re_export_cycles: bool,
113 pub boundary_violations: bool,
114 pub stale_suppressions: bool,
115 pub unused_catalog_entries: bool,
116 pub empty_catalog_groups: bool,
117 pub unresolved_catalog_references: bool,
118 pub unused_dependency_overrides: bool,
119 pub misconfigured_dependency_overrides: bool,
120}
121
122#[derive(Debug, Clone, Default)]
124pub struct DeadCodeOptions {
125 pub analysis: AnalysisOptions,
126 pub filters: DeadCodeFilters,
127 pub files: Vec<PathBuf>,
128 pub include_entry_exports: bool,
129}
130
131#[derive(Debug, Clone, Copy, Default)]
133pub enum DuplicationMode {
134 Strict,
135 #[default]
136 Mild,
137 Weak,
138 Semantic,
139}
140
141impl DuplicationMode {
142 const fn to_cli(self) -> DupesMode {
143 match self {
144 Self::Strict => DupesMode::Strict,
145 Self::Mild => DupesMode::Mild,
146 Self::Weak => DupesMode::Weak,
147 Self::Semantic => DupesMode::Semantic,
148 }
149 }
150}
151
152#[derive(Debug, Clone)]
154pub struct DuplicationOptions {
155 pub analysis: AnalysisOptions,
156 pub mode: DuplicationMode,
157 pub min_tokens: usize,
158 pub min_lines: usize,
159 pub min_occurrences: usize,
166 pub threshold: f64,
167 pub skip_local: bool,
168 pub cross_language: bool,
169 pub ignore_imports: bool,
170 pub top: Option<usize>,
171}
172
173impl Default for DuplicationOptions {
174 fn default() -> Self {
175 Self {
176 analysis: AnalysisOptions::default(),
177 mode: DuplicationMode::Mild,
178 min_tokens: 50,
179 min_lines: 5,
180 min_occurrences: 2,
181 threshold: 0.0,
182 skip_local: false,
183 cross_language: false,
184 ignore_imports: false,
185 top: None,
186 }
187 }
188}
189
190#[derive(Debug, Clone, Copy, Default)]
192pub enum ComplexitySort {
193 #[default]
194 Cyclomatic,
195 Cognitive,
196 Lines,
197 Severity,
198}
199
200impl ComplexitySort {
201 const fn to_cli(self) -> SortBy {
202 match self {
203 Self::Severity => SortBy::Severity,
204 Self::Cyclomatic => SortBy::Cyclomatic,
205 Self::Cognitive => SortBy::Cognitive,
206 Self::Lines => SortBy::Lines,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Copy, Default)]
213pub enum OwnershipEmailMode {
214 Raw,
215 #[default]
216 Handle,
217 Anonymized,
218 Hash,
220}
221
222impl OwnershipEmailMode {
223 const fn to_config(self) -> EmailMode {
224 match self {
225 Self::Raw => EmailMode::Raw,
226 Self::Handle => EmailMode::Handle,
227 Self::Anonymized => EmailMode::Anonymized,
228 Self::Hash => EmailMode::Hash,
229 }
230 }
231}
232
233#[derive(Debug, Clone, Copy)]
235pub enum TargetEffort {
236 Low,
237 Medium,
238 High,
239}
240
241impl TargetEffort {
242 const fn to_cli(self) -> EffortEstimate {
243 match self {
244 Self::Low => EffortEstimate::Low,
245 Self::Medium => EffortEstimate::Medium,
246 Self::High => EffortEstimate::High,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Default)]
253pub struct ComplexityOptions {
254 pub analysis: AnalysisOptions,
255 pub max_cyclomatic: Option<u16>,
256 pub max_cognitive: Option<u16>,
257 pub max_crap: Option<f64>,
258 pub top: Option<usize>,
259 pub sort: ComplexitySort,
260 pub complexity: bool,
261 pub file_scores: bool,
262 pub coverage_gaps: bool,
263 pub hotspots: bool,
264 pub ownership: bool,
265 pub ownership_emails: Option<OwnershipEmailMode>,
266 pub targets: bool,
267 pub effort: Option<TargetEffort>,
268 pub score: bool,
269 pub since: Option<String>,
270 pub min_commits: Option<u32>,
271 pub coverage: Option<PathBuf>,
272 pub coverage_root: Option<PathBuf>,
273}
274
275struct ResolvedAnalysisOptions {
276 root: PathBuf,
277 config_path: Option<PathBuf>,
278 no_cache: bool,
279 threads: usize,
280 pool: rayon::ThreadPool,
281 diff: Option<LoadedDiff>,
282 production_override: Option<bool>,
283 changed_since: Option<String>,
284 workspace: Option<Vec<String>>,
285 changed_workspaces: Option<String>,
286 explain: bool,
287}
288
289impl AnalysisOptions {
290 fn resolve(&self) -> ProgrammaticResult<ResolvedAnalysisOptions> {
291 if self.threads == Some(0) {
292 return Err(
293 ProgrammaticError::new("`threads` must be greater than 0", 2)
294 .with_code("FALLOW_INVALID_THREADS")
295 .with_context("analysis.threads"),
296 );
297 }
298 if self.workspace.is_some() && self.changed_workspaces.is_some() {
299 return Err(ProgrammaticError::new(
300 "`workspace` and `changed_workspaces` are mutually exclusive",
301 2,
302 )
303 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
304 .with_context("analysis.workspace"));
305 }
306
307 let root = if let Some(root) = &self.root {
308 root.clone()
309 } else {
310 std::env::current_dir().map_err(|err| {
311 ProgrammaticError::new(
312 format!("failed to resolve current working directory: {err}"),
313 2,
314 )
315 .with_code("FALLOW_CWD_UNAVAILABLE")
316 .with_context("analysis.root")
317 })?
318 };
319
320 if !root.exists() {
321 return Err(ProgrammaticError::new(
322 format!("analysis root does not exist: {}", root.display()),
323 2,
324 )
325 .with_code("FALLOW_INVALID_ROOT")
326 .with_context("analysis.root"));
327 }
328 if !root.is_dir() {
329 return Err(ProgrammaticError::new(
330 format!("analysis root is not a directory: {}", root.display()),
331 2,
332 )
333 .with_code("FALLOW_INVALID_ROOT")
334 .with_context("analysis.root"));
335 }
336
337 if let Some(config_path) = &self.config_path
338 && !config_path.exists()
339 {
340 return Err(ProgrammaticError::new(
341 format!("config file does not exist: {}", config_path.display()),
342 2,
343 )
344 .with_code("FALLOW_INVALID_CONFIG_PATH")
345 .with_context("analysis.configPath"));
346 }
347
348 let threads = self.threads.unwrap_or_else(default_threads);
349 let pool = crate::rayon_pool::build_thread_pool(threads).map_err(|err| {
350 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
351 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
352 .with_context("analysis.threads")
353 })?;
354 let diff = self
355 .diff_file
356 .as_deref()
357 .map(|path| load_explicit_diff_file(path, &root))
358 .transpose()?;
359 let production_override = self
360 .production_override
361 .or_else(|| self.production.then_some(true));
362
363 Ok(ResolvedAnalysisOptions {
364 root,
365 config_path: self.config_path.clone(),
366 no_cache: self.no_cache,
367 threads,
368 pool,
369 diff,
370 production_override,
371 changed_since: self.changed_since.clone(),
372 workspace: self.workspace.clone(),
373 changed_workspaces: self.changed_workspaces.clone(),
374 explain: self.explain,
375 })
376 }
377}
378
379impl ResolvedAnalysisOptions {
380 fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
381 self.pool.install(f)
382 }
383
384 fn diff_index(&self) -> Option<&DiffIndex> {
385 self.diff.as_ref().map(|loaded| &loaded.index)
386 }
387}
388
389fn default_threads() -> usize {
390 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
391}
392
393fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<LoadedDiff> {
394 if path == Path::new("-") {
395 return Err(ProgrammaticError::new(
396 "`diff_file` does not support stdin; pass a file path",
397 2,
398 )
399 .with_code("FALLOW_INVALID_DIFF_FILE")
400 .with_context("analysis.diffFile"));
401 }
402
403 let abs = if crate::path_util::is_absolute_path_any_platform(path) {
404 path.to_path_buf()
405 } else {
406 root.join(path)
407 };
408
409 let meta = std::fs::metadata(&abs).map_err(|err| {
410 ProgrammaticError::new(
411 format!(
412 "diff file does not exist or cannot be read: {} ({err})",
413 abs.display()
414 ),
415 2,
416 )
417 .with_code("FALLOW_INVALID_DIFF_FILE")
418 .with_context("analysis.diffFile")
419 })?;
420 if !meta.is_file() {
421 return Err(ProgrammaticError::new(
422 format!("diff path is not a file: {}", abs.display()),
423 2,
424 )
425 .with_code("FALLOW_INVALID_DIFF_FILE")
426 .with_context("analysis.diffFile"));
427 }
428 if meta.len() > MAX_DIFF_BYTES {
429 return Err(ProgrammaticError::new(
430 format!(
431 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
432 meta.len(),
433 abs.display()
434 ),
435 2,
436 )
437 .with_code("FALLOW_INVALID_DIFF_FILE")
438 .with_context("analysis.diffFile"));
439 }
440
441 let text = std::fs::read_to_string(&abs).map_err(|err| {
442 ProgrammaticError::new(
443 format!("failed to read diff file {}: {err}", abs.display()),
444 2,
445 )
446 .with_code("FALLOW_INVALID_DIFF_FILE")
447 .with_context("analysis.diffFile")
448 })?;
449
450 Ok(LoadedDiff {
451 index: DiffIndex::from_unified_diff(&text),
452 })
453}
454
455fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
456 if let serde_json::Value::Object(map) = output {
457 map.insert("_meta".to_string(), meta);
458 }
459}
460
461fn build_dead_code_json(
462 results: &AnalysisResults,
463 root: &Path,
464 elapsed: std::time::Duration,
465 explain: bool,
466 config_fixable: bool,
467) -> ProgrammaticResult<serde_json::Value> {
468 let mut output =
469 crate::report::build_json_with_config_fixable(results, root, elapsed, config_fixable)
470 .map_err(|err| {
471 ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
472 .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
473 .with_context("dead-code")
474 })?;
475 if explain {
476 insert_meta(&mut output, crate::explain::check_meta());
477 }
478 Ok(output)
479}
480
481fn to_issue_filters(filters: &DeadCodeFilters) -> IssueFilters {
482 IssueFilters {
483 unused_files: filters.unused_files,
484 unused_exports: filters.unused_exports,
485 unused_deps: filters.unused_deps,
486 unused_types: filters.unused_types,
487 private_type_leaks: filters.private_type_leaks,
488 unused_enum_members: filters.unused_enum_members,
489 unused_class_members: filters.unused_class_members,
490 unresolved_imports: filters.unresolved_imports,
491 unlisted_deps: filters.unlisted_deps,
492 duplicate_exports: filters.duplicate_exports,
493 circular_deps: filters.circular_deps,
494 re_export_cycles: filters.re_export_cycles,
495 boundary_violations: filters.boundary_violations,
496 stale_suppressions: filters.stale_suppressions,
497 unused_catalog_entries: filters.unused_catalog_entries,
498 empty_catalog_groups: filters.empty_catalog_groups,
499 unresolved_catalog_references: filters.unresolved_catalog_references,
500 unused_dependency_overrides: filters.unused_dependency_overrides,
501 misconfigured_dependency_overrides: filters.misconfigured_dependency_overrides,
502 }
503}
504
505fn generic_analysis_error(command: &str) -> ProgrammaticError {
506 let code = format!(
507 "FALLOW_{}_FAILED",
508 command.replace('-', "_").to_ascii_uppercase()
509 );
510 ProgrammaticError::new(format!("{command} failed"), 2)
511 .with_code(code)
512 .with_context(format!("fallow {command}"))
513 .with_help(format!(
514 "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
515 ))
516}
517
518fn build_check_options<'a>(
519 resolved: &'a ResolvedAnalysisOptions,
520 options: &'a DeadCodeOptions,
521 filters: &'a IssueFilters,
522 trace_opts: &'a TraceOptions,
523) -> CheckOptions<'a> {
524 CheckOptions {
525 root: &resolved.root,
526 config_path: &resolved.config_path,
527 output: OutputFormat::Human,
528 no_cache: resolved.no_cache,
529 threads: resolved.threads,
530 quiet: true,
531 fail_on_issues: false,
532 filters,
533 changed_since: resolved.changed_since.as_deref(),
534 diff_index: resolved.diff_index(),
535 use_shared_diff_index: false,
536 baseline: None,
537 save_baseline: None,
538 sarif_file: None,
539 production: resolved.production_override.unwrap_or(false),
540 production_override: resolved.production_override,
541 workspace: resolved.workspace.as_deref(),
542 changed_workspaces: resolved.changed_workspaces.as_deref(),
543 group_by: None,
544 include_dupes: false,
545 trace_opts,
546 explain: resolved.explain,
547 top: None,
548 file: &options.files,
549 include_entry_exports: options.include_entry_exports,
550 summary: false,
551 regression_opts: crate::regression::RegressionOpts {
552 fail_on_regression: false,
553 tolerance: crate::regression::Tolerance::Absolute(0),
554 regression_baseline_file: None,
555 save_target: crate::regression::SaveRegressionTarget::None,
556 scoped: false,
557 quiet: true,
558 output: fallow_config::OutputFormat::Json,
559 },
560 retain_modules_for_health: false,
561 defer_performance: false,
562 }
563}
564
565fn filter_for_circular_dependencies(results: &AnalysisResults) -> AnalysisResults {
566 let mut filtered = results.clone();
567 filtered.unused_files.clear();
568 filtered.unused_exports.clear();
569 filtered.unused_types.clear();
570 filtered.private_type_leaks.clear();
571 filtered.unused_dependencies.clear();
572 filtered.unused_dev_dependencies.clear();
573 filtered.unused_optional_dependencies.clear();
574 filtered.unused_enum_members.clear();
575 filtered.unused_class_members.clear();
576 filtered.unresolved_imports.clear();
577 filtered.unlisted_dependencies.clear();
578 filtered.duplicate_exports.clear();
579 filtered.type_only_dependencies.clear();
580 filtered.test_only_dependencies.clear();
581 filtered.boundary_violations.clear();
582 filtered.stale_suppressions.clear();
583 filtered
584}
585
586fn filter_for_boundary_violations(results: &AnalysisResults) -> AnalysisResults {
587 let mut filtered = results.clone();
588 filtered.unused_files.clear();
589 filtered.unused_exports.clear();
590 filtered.unused_types.clear();
591 filtered.private_type_leaks.clear();
592 filtered.unused_dependencies.clear();
593 filtered.unused_dev_dependencies.clear();
594 filtered.unused_optional_dependencies.clear();
595 filtered.unused_enum_members.clear();
596 filtered.unused_class_members.clear();
597 filtered.unresolved_imports.clear();
598 filtered.unlisted_dependencies.clear();
599 filtered.duplicate_exports.clear();
600 filtered.type_only_dependencies.clear();
601 filtered.test_only_dependencies.clear();
602 filtered.circular_dependencies.clear();
603 filtered.stale_suppressions.clear();
604 filtered
605}
606
607pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
609 let resolved = options.analysis.resolve()?;
610 resolved.install(|| {
611 let filters = to_issue_filters(&options.filters);
612 let trace_opts = TraceOptions {
613 trace_export: None,
614 trace_file: None,
615 trace_dependency: None,
616 performance: false,
617 };
618 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
619 let result = crate::check::execute_check(&check_options)
620 .map_err(|_| generic_analysis_error("dead-code"))?;
621 build_dead_code_json(
622 &result.results,
623 &result.config.root,
624 result.elapsed,
625 resolved.explain,
626 result.config_fixable,
627 )
628 })
629}
630
631pub fn detect_circular_dependencies(
634 options: &DeadCodeOptions,
635) -> ProgrammaticResult<serde_json::Value> {
636 let resolved = options.analysis.resolve()?;
637 resolved.install(|| {
638 let filters = to_issue_filters(&options.filters);
639 let trace_opts = TraceOptions {
640 trace_export: None,
641 trace_file: None,
642 trace_dependency: None,
643 performance: false,
644 };
645 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
646 let result = crate::check::execute_check(&check_options)
647 .map_err(|_| generic_analysis_error("dead-code"))?;
648 let filtered = filter_for_circular_dependencies(&result.results);
649 build_dead_code_json(
650 &filtered,
651 &result.config.root,
652 result.elapsed,
653 resolved.explain,
654 result.config_fixable,
655 )
656 })
657}
658
659pub fn detect_boundary_violations(
662 options: &DeadCodeOptions,
663) -> ProgrammaticResult<serde_json::Value> {
664 let resolved = options.analysis.resolve()?;
665 resolved.install(|| {
666 let filters = to_issue_filters(&options.filters);
667 let trace_opts = TraceOptions {
668 trace_export: None,
669 trace_file: None,
670 trace_dependency: None,
671 performance: false,
672 };
673 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
674 let result = crate::check::execute_check(&check_options)
675 .map_err(|_| generic_analysis_error("dead-code"))?;
676 let filtered = filter_for_boundary_violations(&result.results);
677 build_dead_code_json(
678 &filtered,
679 &result.config.root,
680 result.elapsed,
681 resolved.explain,
682 result.config_fixable,
683 )
684 })
685}
686
687pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
689 let resolved = options.analysis.resolve()?;
690 resolved.install(|| {
691 let dupes_options = DupesOptions {
692 root: &resolved.root,
693 config_path: &resolved.config_path,
694 output: OutputFormat::Human,
695 no_cache: resolved.no_cache,
696 threads: resolved.threads,
697 quiet: true,
698 mode: Some(options.mode.to_cli()),
702 min_tokens: Some(options.min_tokens),
703 min_lines: Some(options.min_lines),
704 min_occurrences: Some(options.min_occurrences),
705 threshold: Some(options.threshold),
706 skip_local: options.skip_local,
707 cross_language: options.cross_language,
708 ignore_imports: options.ignore_imports,
709 top: options.top,
710 baseline_path: None,
711 save_baseline_path: None,
712 production: resolved.production_override.unwrap_or(false),
713 production_override: resolved.production_override,
714 trace: None,
715 changed_since: resolved.changed_since.as_deref(),
716 diff_index: resolved.diff_index(),
717 use_shared_diff_index: false,
718 changed_files: None,
719 workspace: resolved.workspace.as_deref(),
720 changed_workspaces: resolved.changed_workspaces.as_deref(),
721 explain: resolved.explain,
722 explain_skipped: false,
723 summary: false,
724 group_by: None,
725 performance: false,
728 };
729 let result = crate::dupes::execute_dupes(&dupes_options)
730 .map_err(|_| generic_analysis_error("dupes"))?;
731 build_duplication_json(
732 &result.report,
733 &result.config.root,
734 result.elapsed,
735 resolved.explain,
736 )
737 .map_err(|err| {
738 ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
739 .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
740 .with_context("dupes")
741 })
742 })
743}
744
745fn build_complexity_options<'a>(
746 resolved: &'a ResolvedAnalysisOptions,
747 options: &'a ComplexityOptions,
748) -> HealthOptions<'a> {
749 let ownership = options.ownership || options.ownership_emails.is_some();
750 let hotspots = options.hotspots || ownership;
751 let targets = options.targets || options.effort.is_some();
752 let any_section = options.complexity
753 || options.file_scores
754 || options.coverage_gaps
755 || hotspots
756 || targets
757 || options.score;
758 let eff_score = if any_section { options.score } else { true };
759 let force_full = eff_score;
760 let score_only_output = options.score
761 && !options.complexity
762 && !options.file_scores
763 && !options.coverage_gaps
764 && !hotspots
765 && !targets;
766 let eff_file_scores = if any_section {
767 options.file_scores
768 } else {
769 true
770 } || force_full;
771 let eff_hotspots = if any_section { hotspots } else { true };
772 let eff_complexity = if any_section {
773 options.complexity
774 } else {
775 true
776 };
777 let eff_targets = if any_section { targets } else { true };
778 let eff_coverage_gaps = if any_section {
779 options.coverage_gaps
780 } else {
781 false
782 };
783
784 HealthOptions {
785 root: &resolved.root,
786 config_path: &resolved.config_path,
787 output: OutputFormat::Human,
788 no_cache: resolved.no_cache,
789 threads: resolved.threads,
790 quiet: true,
791 max_cyclomatic: options.max_cyclomatic,
792 max_cognitive: options.max_cognitive,
793 max_crap: options.max_crap,
794 top: options.top,
795 sort: options.sort.to_cli(),
796 production: resolved.production_override.unwrap_or(false),
797 production_override: resolved.production_override,
798 changed_since: resolved.changed_since.as_deref(),
799 diff_index: resolved.diff_index(),
800 use_shared_diff_index: false,
801 workspace: resolved.workspace.as_deref(),
802 changed_workspaces: resolved.changed_workspaces.as_deref(),
803 baseline: None,
804 save_baseline: None,
805 complexity: eff_complexity,
806 file_scores: eff_file_scores,
807 coverage_gaps: eff_coverage_gaps,
808 config_activates_coverage_gaps: !any_section,
809 hotspots: eff_hotspots,
810 ownership: ownership && eff_hotspots,
811 ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
812 targets: eff_targets,
813 force_full,
814 score_only_output,
815 enforce_coverage_gap_gate: true,
816 effort: options.effort.map(TargetEffort::to_cli),
817 score: eff_score,
818 min_score: None,
819 since: options.since.as_deref(),
820 min_commits: options.min_commits,
821 explain: resolved.explain,
822 summary: false,
823 save_snapshot: None,
824 trend: false,
825 group_by: None,
826 coverage: options.coverage.as_deref(),
827 coverage_root: options.coverage_root.as_deref(),
828 performance: false,
829 min_severity: None,
830 runtime_coverage: None,
831 }
832}
833
834pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
836 let resolved = options.analysis.resolve()?;
837 if let Some(path) = &options.coverage
838 && !path.exists()
839 {
840 return Err(ProgrammaticError::new(
841 format!("coverage path does not exist: {}", path.display()),
842 2,
843 )
844 .with_code("FALLOW_INVALID_COVERAGE_PATH")
845 .with_context("health.coverage"));
846 }
847 if let Err(message) =
848 crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
849 {
850 return Err(ProgrammaticError::new(message, 2)
851 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
852 .with_context("health.coverage_root"));
853 }
854
855 resolved.install(|| {
856 let health_options = build_complexity_options(&resolved, options);
857 let result = crate::health::execute_health(&health_options)
858 .map_err(|_| generic_analysis_error("health"))?;
859 build_health_json(
860 &result.report,
861 &result.config.root,
862 result.elapsed,
863 resolved.explain,
864 )
865 .map_err(|err| {
866 ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
867 .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
868 .with_context("health")
869 })
870 })
871}
872
873pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
875 compute_complexity(options)
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881 use crate::report::test_helpers::sample_results;
882 use std::process::Command;
883
884 const SHARED_DIFF_CHILD_ENV: &str = "FALLOW_PROGRAMMATIC_SHARED_DIFF_CHILD";
885 const SHARED_DIFF_CHILD_TEST: &str =
886 "programmatic::tests::programmatic_without_diff_file_ignores_shared_diff_cache";
887
888 #[test]
889 fn circular_dependency_filter_clears_other_issue_types() {
890 let root = PathBuf::from("/project");
891 let results = sample_results(&root);
892 let filtered = filter_for_circular_dependencies(&results);
893 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
894 .expect("should serialize");
895
896 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
897 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
898 assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
899 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
900 }
901
902 #[test]
903 fn boundary_violation_filter_clears_other_issue_types() {
904 let root = PathBuf::from("/project");
905 let results = sample_results(&root);
906 let filtered = filter_for_boundary_violations(&results);
907 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
908 .expect("should serialize");
909
910 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
911 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
912 assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
913 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
914 }
915
916 #[test]
917 fn dead_code_without_production_override_uses_per_analysis_config() {
918 let dir = tempfile::tempdir().expect("temp dir");
919 let root = dir.path();
920 std::fs::create_dir_all(root.join("src")).unwrap();
921 std::fs::write(
922 root.join("package.json"),
923 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
924 )
925 .unwrap();
926 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
927 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
928 std::fs::write(
929 root.join(".fallowrc.json"),
930 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
931 )
932 .unwrap();
933
934 let options = DeadCodeOptions {
935 analysis: AnalysisOptions {
936 root: Some(root.to_path_buf()),
937 ..AnalysisOptions::default()
938 },
939 ..DeadCodeOptions::default()
940 };
941 let json = detect_dead_code(&options).expect("analysis should succeed");
942 let paths = unused_file_paths(&json);
943
944 assert!(
945 !paths.iter().any(|path| path.ends_with("utils.test.ts")),
946 "omitted production option should defer to production.deadCode=true config: {paths:?}"
947 );
948 }
949
950 #[test]
951 fn dead_code_explicit_production_false_overrides_config() {
952 let dir = tempfile::tempdir().expect("temp dir");
953 let root = dir.path();
954 std::fs::create_dir_all(root.join("src")).unwrap();
955 std::fs::write(
956 root.join("package.json"),
957 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
958 )
959 .unwrap();
960 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
961 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
962 std::fs::write(
963 root.join(".fallowrc.json"),
964 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
965 )
966 .unwrap();
967
968 let options = DeadCodeOptions {
969 analysis: AnalysisOptions {
970 root: Some(root.to_path_buf()),
971 production_override: Some(false),
972 ..AnalysisOptions::default()
973 },
974 ..DeadCodeOptions::default()
975 };
976 let json = detect_dead_code(&options).expect("analysis should succeed");
977 let paths = unused_file_paths(&json);
978
979 assert!(
980 paths.iter().any(|path| path.ends_with("utils.test.ts")),
981 "explicit production=false should include test files despite config: {paths:?}"
982 );
983 }
984
985 #[test]
986 fn analysis_resolve_uses_per_call_thread_pool() {
987 let dir = tempfile::tempdir().expect("temp dir");
988 let root = dir.path();
989
990 let one = AnalysisOptions {
991 root: Some(root.to_path_buf()),
992 threads: Some(1),
993 ..AnalysisOptions::default()
994 }
995 .resolve()
996 .expect("one-thread options should resolve");
997 let two = AnalysisOptions {
998 root: Some(root.to_path_buf()),
999 threads: Some(2),
1000 ..AnalysisOptions::default()
1001 }
1002 .resolve()
1003 .expect("two-thread options should resolve");
1004
1005 assert_eq!(one.install(rayon::current_num_threads), 1);
1006 assert_eq!(two.install(rayon::current_num_threads), 2);
1007 }
1008
1009 #[test]
1010 fn explicit_diff_file_scopes_dead_code_per_call() {
1011 let dir = tempfile::tempdir().expect("temp dir");
1012 let root = dir.path();
1013 std::fs::create_dir_all(root.join("src")).unwrap();
1014 std::fs::write(
1015 root.join("package.json"),
1016 r#"{"name":"programmatic-diff","main":"src/index.ts"}"#,
1017 )
1018 .unwrap();
1019 std::fs::write(
1020 root.join("src/index.ts"),
1021 "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1022 )
1023 .unwrap();
1024 std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1025 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1026 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1027 std::fs::write(
1028 root.join("a.diff"),
1029 diff_for("src/a.ts", "export const deadA = 1;\n"),
1030 )
1031 .unwrap();
1032 std::fs::write(
1033 root.join("b.diff"),
1034 diff_for("src/b.ts", "export const deadB = 1;\n"),
1035 )
1036 .unwrap();
1037
1038 let filters = DeadCodeFilters {
1039 unused_exports: true,
1040 ..DeadCodeFilters::default()
1041 };
1042
1043 let a_json = detect_dead_code(&DeadCodeOptions {
1044 analysis: AnalysisOptions {
1045 root: Some(root.to_path_buf()),
1046 diff_file: Some(PathBuf::from("a.diff")),
1047 ..AnalysisOptions::default()
1048 },
1049 filters: filters.clone(),
1050 ..DeadCodeOptions::default()
1051 })
1052 .expect("a-scoped analysis should succeed");
1053 let b_json = detect_dead_code(&DeadCodeOptions {
1054 analysis: AnalysisOptions {
1055 root: Some(root.to_path_buf()),
1056 diff_file: Some(PathBuf::from("b.diff")),
1057 ..AnalysisOptions::default()
1058 },
1059 filters,
1060 ..DeadCodeOptions::default()
1061 })
1062 .expect("b-scoped analysis should succeed");
1063
1064 assert_eq!(unused_export_names(&a_json), vec!["deadA"]);
1065 assert_eq!(unused_export_names(&b_json), vec!["deadB"]);
1066 }
1067
1068 #[test]
1069 fn programmatic_without_diff_file_ignores_shared_diff_cache() {
1070 if std::env::var_os(SHARED_DIFF_CHILD_ENV).is_some() {
1071 run_programmatic_shared_diff_child();
1072 return;
1073 }
1074
1075 let current_exe = std::env::current_exe().expect("current test binary should be known");
1076 let output = Command::new(current_exe)
1077 .arg("--exact")
1078 .arg(SHARED_DIFF_CHILD_TEST)
1079 .arg("--nocapture")
1080 .env(SHARED_DIFF_CHILD_ENV, "1")
1081 .output()
1082 .expect("shared diff child should start");
1083
1084 assert!(
1085 output.status.success(),
1086 "shared diff child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
1087 output.status.code(),
1088 String::from_utf8_lossy(&output.stdout),
1089 String::from_utf8_lossy(&output.stderr)
1090 );
1091 }
1092
1093 fn run_programmatic_shared_diff_child() {
1094 let dir = tempfile::tempdir().expect("temp dir");
1095 let root = dir.path();
1096 std::fs::create_dir_all(root.join("src")).unwrap();
1097 std::fs::write(
1098 root.join("package.json"),
1099 r#"{"name":"programmatic-shared-diff","main":"src/index.ts"}"#,
1100 )
1101 .unwrap();
1102 std::fs::write(
1103 root.join("src/index.ts"),
1104 "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1105 )
1106 .unwrap();
1107 std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1108 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1109 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1110 std::fs::write(
1111 root.join("a.diff"),
1112 diff_for("src/a.ts", "export const deadA = 1;\n"),
1113 )
1114 .unwrap();
1115
1116 let source = crate::report::ci::diff_filter::DiffSource::Flag(root.join("a.diff"));
1117 let loaded = crate::report::ci::diff_filter::init_shared_diff(Some(&source), true);
1118 assert!(loaded.is_some(), "shared diff should load in child process");
1119
1120 let json = detect_dead_code(&DeadCodeOptions {
1121 analysis: AnalysisOptions {
1122 root: Some(root.to_path_buf()),
1123 ..AnalysisOptions::default()
1124 },
1125 filters: DeadCodeFilters {
1126 unused_exports: true,
1127 ..DeadCodeFilters::default()
1128 },
1129 ..DeadCodeOptions::default()
1130 })
1131 .expect("analysis without explicit diff should succeed");
1132
1133 assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
1134 }
1135
1136 #[test]
1137 fn explicit_diff_file_rejects_stdin_sentinel() {
1138 let dir = tempfile::tempdir().expect("temp dir");
1139 let Err(error) = AnalysisOptions {
1140 root: Some(dir.path().to_path_buf()),
1141 diff_file: Some(PathBuf::from("-")),
1142 ..AnalysisOptions::default()
1143 }
1144 .resolve() else {
1145 panic!("stdin sentinel is not part of the programmatic API");
1146 };
1147
1148 assert_eq!(error.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1149 assert_eq!(error.context.as_deref(), Some("analysis.diffFile"));
1150 }
1151
1152 fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
1153 json["unused_files"]
1154 .as_array()
1155 .unwrap()
1156 .iter()
1157 .filter_map(|file| file["path"].as_str())
1158 .map(str::to_owned)
1159 .collect()
1160 }
1161
1162 fn unused_export_names(json: &serde_json::Value) -> Vec<String> {
1163 let mut names: Vec<String> = json["unused_exports"]
1164 .as_array()
1165 .unwrap()
1166 .iter()
1167 .filter_map(|export| export["export_name"].as_str())
1168 .map(str::to_owned)
1169 .collect();
1170 names.sort();
1171 names
1172 }
1173
1174 fn diff_for(path: &str, line: &str) -> String {
1175 format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+{line}")
1176 }
1177}