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 "legacy-envelope",
26];
27
28#[derive(Debug, Clone, Serialize)]
30pub struct ProgrammaticError {
31 pub message: String,
32 pub exit_code: u8,
33 pub code: Option<String>,
34 pub help: Option<String>,
35 pub context: Option<String>,
36}
37
38impl ProgrammaticError {
39 #[must_use]
40 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
41 Self {
42 message: message.into(),
43 exit_code,
44 code: None,
45 help: None,
46 context: None,
47 }
48 }
49
50 #[must_use]
51 pub fn with_help(mut self, help: impl Into<String>) -> Self {
52 self.help = Some(help.into());
53 self
54 }
55
56 #[must_use]
57 pub fn with_code(mut self, code: impl Into<String>) -> Self {
58 self.code = Some(code.into());
59 self
60 }
61
62 #[must_use]
63 pub fn with_context(mut self, context: impl Into<String>) -> Self {
64 self.context = Some(context.into());
65 self
66 }
67}
68
69impl std::fmt::Display for ProgrammaticError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(f, "{}", self.message)
72 }
73}
74
75impl std::error::Error for ProgrammaticError {}
76
77type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
78
79#[derive(Debug, Clone, Default)]
81pub struct AnalysisOptions {
82 pub root: Option<PathBuf>,
83 pub config_path: Option<PathBuf>,
84 pub no_cache: bool,
85 pub threads: Option<usize>,
86 pub diff_file: Option<PathBuf>,
87 pub production: bool,
90 pub production_override: Option<bool>,
93 pub changed_since: Option<String>,
94 pub workspace: Option<Vec<String>>,
95 pub changed_workspaces: Option<String>,
96 pub explain: bool,
97 pub legacy_envelope: bool,
99}
100
101#[derive(Debug, Clone, Default)]
103pub struct DeadCodeFilters {
104 pub unused_files: bool,
105 pub unused_exports: bool,
106 pub unused_deps: bool,
107 pub unused_types: bool,
108 pub private_type_leaks: bool,
109 pub unused_enum_members: bool,
110 pub unused_class_members: bool,
111 pub unused_store_members: bool,
112 pub unprovided_injects: bool,
113 pub unrendered_components: bool,
114 pub unused_component_props: bool,
115 pub unused_component_emits: bool,
116 pub unused_component_inputs: bool,
117 pub unused_component_outputs: bool,
118 pub unused_svelte_events: bool,
119 pub unused_server_actions: bool,
120 pub unused_load_data_keys: bool,
121 pub unresolved_imports: bool,
122 pub unlisted_deps: bool,
123 pub duplicate_exports: bool,
124 pub circular_deps: bool,
125 pub re_export_cycles: bool,
126 pub boundary_violations: bool,
127 pub policy_violations: bool,
128 pub stale_suppressions: bool,
129 pub unused_catalog_entries: bool,
130 pub empty_catalog_groups: bool,
131 pub unresolved_catalog_references: bool,
132 pub unused_dependency_overrides: bool,
133 pub misconfigured_dependency_overrides: bool,
134}
135
136#[derive(Debug, Clone, Default)]
138pub struct DeadCodeOptions {
139 pub analysis: AnalysisOptions,
140 pub filters: DeadCodeFilters,
141 pub files: Vec<PathBuf>,
142 pub include_entry_exports: bool,
143}
144
145#[derive(Debug, Clone, Copy, Default)]
147pub enum DuplicationMode {
148 Strict,
149 #[default]
150 Mild,
151 Weak,
152 Semantic,
153}
154
155impl DuplicationMode {
156 const fn to_cli(self) -> DupesMode {
157 match self {
158 Self::Strict => DupesMode::Strict,
159 Self::Mild => DupesMode::Mild,
160 Self::Weak => DupesMode::Weak,
161 Self::Semantic => DupesMode::Semantic,
162 }
163 }
164}
165
166#[derive(Debug, Clone)]
168pub struct DuplicationOptions {
169 pub analysis: AnalysisOptions,
170 pub mode: DuplicationMode,
171 pub min_tokens: usize,
172 pub min_lines: usize,
173 pub min_occurrences: usize,
180 pub threshold: f64,
181 pub skip_local: bool,
182 pub cross_language: bool,
183 pub ignore_imports: Option<bool>,
187 pub top: Option<usize>,
188}
189
190impl Default for DuplicationOptions {
191 fn default() -> Self {
192 Self {
193 analysis: AnalysisOptions::default(),
194 mode: DuplicationMode::Mild,
195 min_tokens: 50,
196 min_lines: 5,
197 min_occurrences: 2,
198 threshold: 0.0,
199 skip_local: false,
200 cross_language: false,
201 ignore_imports: None,
202 top: None,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, Default)]
209pub enum ComplexitySort {
210 #[default]
211 Cyclomatic,
212 Cognitive,
213 Lines,
214 Severity,
215}
216
217impl ComplexitySort {
218 const fn to_cli(self) -> SortBy {
219 match self {
220 Self::Severity => SortBy::Severity,
221 Self::Cyclomatic => SortBy::Cyclomatic,
222 Self::Cognitive => SortBy::Cognitive,
223 Self::Lines => SortBy::Lines,
224 }
225 }
226}
227
228#[derive(Debug, Clone, Copy, Default)]
230pub enum OwnershipEmailMode {
231 Raw,
232 #[default]
233 Handle,
234 Anonymized,
235 Hash,
237}
238
239impl OwnershipEmailMode {
240 const fn to_config(self) -> EmailMode {
241 match self {
242 Self::Raw => EmailMode::Raw,
243 Self::Handle => EmailMode::Handle,
244 Self::Anonymized => EmailMode::Anonymized,
245 Self::Hash => EmailMode::Hash,
246 }
247 }
248}
249
250#[derive(Debug, Clone, Copy)]
252pub enum TargetEffort {
253 Low,
254 Medium,
255 High,
256}
257
258impl TargetEffort {
259 const fn to_cli(self) -> EffortEstimate {
260 match self {
261 Self::Low => EffortEstimate::Low,
262 Self::Medium => EffortEstimate::Medium,
263 Self::High => EffortEstimate::High,
264 }
265 }
266}
267
268#[derive(Debug, Clone, Default)]
270pub struct ComplexityOptions {
271 pub analysis: AnalysisOptions,
272 pub max_cyclomatic: Option<u16>,
273 pub max_cognitive: Option<u16>,
274 pub max_crap: Option<f64>,
275 pub top: Option<usize>,
276 pub sort: ComplexitySort,
277 pub complexity: bool,
278 pub file_scores: bool,
279 pub coverage_gaps: bool,
280 pub hotspots: bool,
281 pub ownership: bool,
282 pub ownership_emails: Option<OwnershipEmailMode>,
283 pub targets: bool,
284 pub effort: Option<TargetEffort>,
285 pub score: bool,
286 pub since: Option<String>,
287 pub min_commits: Option<u32>,
288 pub coverage: Option<PathBuf>,
289 pub coverage_root: Option<PathBuf>,
290}
291
292struct ResolvedAnalysisOptions {
293 root: PathBuf,
294 config_path: Option<PathBuf>,
295 no_cache: bool,
296 threads: usize,
297 pool: rayon::ThreadPool,
298 diff: Option<LoadedDiff>,
299 production_override: Option<bool>,
300 changed_since: Option<String>,
301 workspace: Option<Vec<String>>,
302 changed_workspaces: Option<String>,
303 explain: bool,
304 legacy_envelope: bool,
305}
306
307impl AnalysisOptions {
308 fn resolve(&self) -> ProgrammaticResult<ResolvedAnalysisOptions> {
309 if self.threads == Some(0) {
310 return Err(
311 ProgrammaticError::new("`threads` must be greater than 0", 2)
312 .with_code("FALLOW_INVALID_THREADS")
313 .with_context("analysis.threads"),
314 );
315 }
316 if self.workspace.is_some() && self.changed_workspaces.is_some() {
317 return Err(ProgrammaticError::new(
318 "`workspace` and `changed_workspaces` are mutually exclusive",
319 2,
320 )
321 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
322 .with_context("analysis.workspace"));
323 }
324
325 let root = if let Some(root) = &self.root {
326 root.clone()
327 } else {
328 std::env::current_dir().map_err(|err| {
329 ProgrammaticError::new(
330 format!("failed to resolve current working directory: {err}"),
331 2,
332 )
333 .with_code("FALLOW_CWD_UNAVAILABLE")
334 .with_context("analysis.root")
335 })?
336 };
337
338 if !root.exists() {
339 return Err(ProgrammaticError::new(
340 format!("analysis root does not exist: {}", root.display()),
341 2,
342 )
343 .with_code("FALLOW_INVALID_ROOT")
344 .with_context("analysis.root"));
345 }
346 if !root.is_dir() {
347 return Err(ProgrammaticError::new(
348 format!("analysis root is not a directory: {}", root.display()),
349 2,
350 )
351 .with_code("FALLOW_INVALID_ROOT")
352 .with_context("analysis.root"));
353 }
354
355 if let Some(config_path) = &self.config_path
356 && !config_path.exists()
357 {
358 return Err(ProgrammaticError::new(
359 format!("config file does not exist: {}", config_path.display()),
360 2,
361 )
362 .with_code("FALLOW_INVALID_CONFIG_PATH")
363 .with_context("analysis.configPath"));
364 }
365
366 let threads = self.threads.unwrap_or_else(default_threads);
367 let pool = crate::rayon_pool::build_thread_pool(threads).map_err(|err| {
368 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
369 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
370 .with_context("analysis.threads")
371 })?;
372 let diff = self
373 .diff_file
374 .as_deref()
375 .map(|path| load_explicit_diff_file(path, &root))
376 .transpose()?;
377 let production_override = self
378 .production_override
379 .or_else(|| self.production.then_some(true));
380
381 Ok(ResolvedAnalysisOptions {
382 root,
383 config_path: self.config_path.clone(),
384 no_cache: self.no_cache,
385 threads,
386 pool,
387 diff,
388 production_override,
389 changed_since: self.changed_since.clone(),
390 workspace: self.workspace.clone(),
391 changed_workspaces: self.changed_workspaces.clone(),
392 explain: self.explain,
393 legacy_envelope: self.legacy_envelope,
394 })
395 }
396}
397
398impl ResolvedAnalysisOptions {
399 fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
400 self.pool.install(f)
401 }
402
403 fn diff_index(&self) -> Option<&DiffIndex> {
404 self.diff.as_ref().map(|loaded| &loaded.index)
405 }
406}
407
408fn default_threads() -> usize {
409 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
410}
411
412fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<LoadedDiff> {
413 if path == Path::new("-") {
414 return Err(ProgrammaticError::new(
415 "`diff_file` does not support stdin; pass a file path",
416 2,
417 )
418 .with_code("FALLOW_INVALID_DIFF_FILE")
419 .with_context("analysis.diffFile"));
420 }
421
422 let abs = if crate::path_util::is_absolute_path_any_platform(path) {
423 path.to_path_buf()
424 } else {
425 root.join(path)
426 };
427
428 let meta = std::fs::metadata(&abs).map_err(|err| {
429 ProgrammaticError::new(
430 format!(
431 "diff file does not exist or cannot be read: {} ({err})",
432 abs.display()
433 ),
434 2,
435 )
436 .with_code("FALLOW_INVALID_DIFF_FILE")
437 .with_context("analysis.diffFile")
438 })?;
439 if !meta.is_file() {
440 return Err(ProgrammaticError::new(
441 format!("diff path is not a file: {}", abs.display()),
442 2,
443 )
444 .with_code("FALLOW_INVALID_DIFF_FILE")
445 .with_context("analysis.diffFile"));
446 }
447 if meta.len() > MAX_DIFF_BYTES {
448 return Err(ProgrammaticError::new(
449 format!(
450 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
451 meta.len(),
452 abs.display()
453 ),
454 2,
455 )
456 .with_code("FALLOW_INVALID_DIFF_FILE")
457 .with_context("analysis.diffFile"));
458 }
459
460 let text = std::fs::read_to_string(&abs).map_err(|err| {
461 ProgrammaticError::new(
462 format!("failed to read diff file {}: {err}", abs.display()),
463 2,
464 )
465 .with_code("FALLOW_INVALID_DIFF_FILE")
466 .with_context("analysis.diffFile")
467 })?;
468
469 Ok(LoadedDiff {
470 index: DiffIndex::from_unified_diff(&text),
471 })
472}
473
474fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
475 if let serde_json::Value::Object(map) = output {
476 let telemetry = map
477 .get("_meta")
478 .and_then(|existing| existing.get("telemetry"))
479 .cloned();
480 let mut meta = meta;
481 if let (Some(telemetry), Some(meta_map)) = (telemetry, meta.as_object_mut()) {
482 meta_map.insert("telemetry".to_string(), telemetry);
483 }
484 map.insert("_meta".to_string(), meta);
485 }
486}
487
488fn apply_programmatic_envelope_options(
489 output: &mut serde_json::Value,
490 resolved: &ResolvedAnalysisOptions,
491) {
492 if resolved.legacy_envelope {
493 crate::output_envelope::remove_root_kind(output);
494 }
495}
496
497fn build_dead_code_json(
498 results: &AnalysisResults,
499 root: &Path,
500 elapsed: std::time::Duration,
501 explain: bool,
502 config_fixable: bool,
503) -> ProgrammaticResult<serde_json::Value> {
504 let mut output =
505 crate::report::build_json_with_config_fixable(results, root, elapsed, config_fixable)
506 .map_err(|err| {
507 ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
508 .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
509 .with_context("dead-code")
510 })?;
511 if explain {
512 insert_meta(&mut output, crate::explain::check_meta());
513 }
514 Ok(output)
517}
518
519fn to_issue_filters(filters: &DeadCodeFilters) -> IssueFilters {
520 IssueFilters {
521 unused_files: filters.unused_files,
522 unused_exports: filters.unused_exports,
523 unused_deps: filters.unused_deps,
524 unused_types: filters.unused_types,
525 private_type_leaks: filters.private_type_leaks,
526 unused_enum_members: filters.unused_enum_members,
527 unused_class_members: filters.unused_class_members,
528 unused_store_members: filters.unused_store_members,
529 unprovided_injects: filters.unprovided_injects,
530 unrendered_components: filters.unrendered_components,
531 unused_component_props: filters.unused_component_props,
532 unused_component_emits: filters.unused_component_emits,
533 unused_component_inputs: filters.unused_component_inputs,
534 unused_component_outputs: filters.unused_component_outputs,
535 unused_svelte_events: filters.unused_svelte_events,
536 unused_server_actions: filters.unused_server_actions,
537 unused_load_data_keys: filters.unused_load_data_keys,
538 unresolved_imports: filters.unresolved_imports,
539 unlisted_deps: filters.unlisted_deps,
540 duplicate_exports: filters.duplicate_exports,
541 circular_deps: filters.circular_deps,
542 re_export_cycles: filters.re_export_cycles,
543 boundary_violations: filters.boundary_violations,
544 policy_violations: filters.policy_violations,
545 stale_suppressions: filters.stale_suppressions,
546 unused_catalog_entries: filters.unused_catalog_entries,
547 empty_catalog_groups: filters.empty_catalog_groups,
548 unresolved_catalog_references: filters.unresolved_catalog_references,
549 unused_dependency_overrides: filters.unused_dependency_overrides,
550 misconfigured_dependency_overrides: filters.misconfigured_dependency_overrides,
551 invalid_client_exports: false,
554 mixed_client_server_barrels: false,
557 misplaced_directives: false,
560 route_collisions: false,
564 dynamic_segment_name_conflicts: false,
565 }
566}
567
568fn generic_analysis_error(command: &str) -> ProgrammaticError {
569 let code = format!(
570 "FALLOW_{}_FAILED",
571 command.replace('-', "_").to_ascii_uppercase()
572 );
573 ProgrammaticError::new(format!("{command} failed"), 2)
574 .with_code(code)
575 .with_context(format!("fallow {command}"))
576 .with_help(format!(
577 "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
578 ))
579}
580
581fn build_check_options<'a>(
582 resolved: &'a ResolvedAnalysisOptions,
583 options: &'a DeadCodeOptions,
584 filters: &'a IssueFilters,
585 trace_opts: &'a TraceOptions,
586) -> CheckOptions<'a> {
587 CheckOptions {
588 root: &resolved.root,
589 config_path: &resolved.config_path,
590 output: OutputFormat::Human,
591 no_cache: resolved.no_cache,
592 threads: resolved.threads,
593 quiet: true,
594 fail_on_issues: false,
595 filters,
596 changed_since: resolved.changed_since.as_deref(),
597 diff_index: resolved.diff_index(),
598 use_shared_diff_index: false,
599 baseline: None,
600 save_baseline: None,
601 sarif_file: None,
602 production: resolved.production_override.unwrap_or(false),
603 production_override: resolved.production_override,
604 workspace: resolved.workspace.as_deref(),
605 changed_workspaces: resolved.changed_workspaces.as_deref(),
606 group_by: None,
607 include_dupes: false,
608 trace_opts,
609 explain: resolved.explain,
610 top: None,
611 file: &options.files,
612 include_entry_exports: options.include_entry_exports,
613 summary: false,
614 regression_opts: crate::regression::RegressionOpts {
615 fail_on_regression: false,
616 tolerance: crate::regression::Tolerance::Absolute(0),
617 regression_baseline_file: None,
618 save_target: crate::regression::SaveRegressionTarget::None,
619 scoped: false,
620 quiet: true,
621 output: fallow_config::OutputFormat::Json,
622 },
623 retain_modules_for_health: false,
624 defer_performance: false,
625 }
626}
627
628fn filter_for_circular_dependencies(results: &AnalysisResults) -> AnalysisResults {
629 let mut filtered = results.clone();
630 filtered.unused_files.clear();
631 filtered.unused_exports.clear();
632 filtered.unused_types.clear();
633 filtered.private_type_leaks.clear();
634 filtered.unused_dependencies.clear();
635 filtered.unused_dev_dependencies.clear();
636 filtered.unused_optional_dependencies.clear();
637 filtered.unused_enum_members.clear();
638 filtered.unused_class_members.clear();
639 filtered.unused_store_members.clear();
640 filtered.unprovided_injects.clear();
641 filtered.unrendered_components.clear();
642 filtered.unused_component_props.clear();
643 filtered.unused_component_emits.clear();
644 filtered.unused_component_inputs.clear();
645 filtered.unused_component_outputs.clear();
646 filtered.unused_svelte_events.clear();
647 filtered.unused_server_actions.clear();
648 filtered.unused_load_data_keys.clear();
649 filtered.unresolved_imports.clear();
650 filtered.unlisted_dependencies.clear();
651 filtered.duplicate_exports.clear();
652 filtered.type_only_dependencies.clear();
653 filtered.test_only_dependencies.clear();
654 filtered.boundary_violations.clear();
655 filtered.boundary_coverage_violations.clear();
656 filtered.boundary_call_violations.clear();
657 filtered.policy_violations.clear();
658 filtered.stale_suppressions.clear();
659 filtered
660}
661
662fn filter_for_boundary_violations(results: &AnalysisResults) -> AnalysisResults {
663 let mut filtered = results.clone();
664 filtered.unused_files.clear();
665 filtered.unused_exports.clear();
666 filtered.unused_types.clear();
667 filtered.private_type_leaks.clear();
668 filtered.unused_dependencies.clear();
669 filtered.unused_dev_dependencies.clear();
670 filtered.unused_optional_dependencies.clear();
671 filtered.unused_enum_members.clear();
672 filtered.unused_class_members.clear();
673 filtered.unused_store_members.clear();
674 filtered.unprovided_injects.clear();
675 filtered.unrendered_components.clear();
676 filtered.unused_component_props.clear();
677 filtered.unused_component_emits.clear();
678 filtered.unused_component_inputs.clear();
679 filtered.unused_component_outputs.clear();
680 filtered.unused_svelte_events.clear();
681 filtered.unused_server_actions.clear();
682 filtered.unused_load_data_keys.clear();
683 filtered.unresolved_imports.clear();
684 filtered.unlisted_dependencies.clear();
685 filtered.duplicate_exports.clear();
686 filtered.type_only_dependencies.clear();
687 filtered.test_only_dependencies.clear();
688 filtered.circular_dependencies.clear();
689 filtered.stale_suppressions.clear();
690 filtered
691}
692
693pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
695 let resolved = options.analysis.resolve()?;
696 resolved.install(|| {
697 let filters = to_issue_filters(&options.filters);
698 let trace_opts = TraceOptions {
699 trace_export: None,
700 trace_file: None,
701 trace_dependency: None,
702 performance: false,
703 };
704 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
705 let result = crate::check::execute_check(&check_options)
706 .map_err(|_| generic_analysis_error("dead-code"))?;
707 let mut output = build_dead_code_json(
708 &result.results,
709 &result.config.root,
710 result.elapsed,
711 resolved.explain,
712 result.config_fixable,
713 )?;
714 apply_programmatic_envelope_options(&mut output, &resolved);
715 Ok(output)
716 })
717}
718
719pub fn detect_circular_dependencies(
722 options: &DeadCodeOptions,
723) -> ProgrammaticResult<serde_json::Value> {
724 let resolved = options.analysis.resolve()?;
725 resolved.install(|| {
726 let filters = to_issue_filters(&options.filters);
727 let trace_opts = TraceOptions {
728 trace_export: None,
729 trace_file: None,
730 trace_dependency: None,
731 performance: false,
732 };
733 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
734 let result = crate::check::execute_check(&check_options)
735 .map_err(|_| generic_analysis_error("dead-code"))?;
736 let filtered = filter_for_circular_dependencies(&result.results);
737 let mut output = build_dead_code_json(
738 &filtered,
739 &result.config.root,
740 result.elapsed,
741 resolved.explain,
742 result.config_fixable,
743 )?;
744 apply_programmatic_envelope_options(&mut output, &resolved);
745 Ok(output)
746 })
747}
748
749pub fn detect_boundary_violations(
753 options: &DeadCodeOptions,
754) -> ProgrammaticResult<serde_json::Value> {
755 let resolved = options.analysis.resolve()?;
756 resolved.install(|| {
757 let filters = to_issue_filters(&options.filters);
758 let trace_opts = TraceOptions {
759 trace_export: None,
760 trace_file: None,
761 trace_dependency: None,
762 performance: false,
763 };
764 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
765 let result = crate::check::execute_check(&check_options)
766 .map_err(|_| generic_analysis_error("dead-code"))?;
767 let filtered = filter_for_boundary_violations(&result.results);
768 let mut output = build_dead_code_json(
769 &filtered,
770 &result.config.root,
771 result.elapsed,
772 resolved.explain,
773 result.config_fixable,
774 )?;
775 apply_programmatic_envelope_options(&mut output, &resolved);
776 Ok(output)
777 })
778}
779
780pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
782 let resolved = options.analysis.resolve()?;
783 resolved.install(|| {
784 let dupes_options = DupesOptions {
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 mode: Some(options.mode.to_cli()),
792 min_tokens: Some(options.min_tokens),
793 min_lines: Some(options.min_lines),
794 min_occurrences: Some(options.min_occurrences),
795 threshold: Some(options.threshold),
796 skip_local: options.skip_local,
797 cross_language: options.cross_language,
798 ignore_imports: options.ignore_imports,
799 top: options.top,
800 baseline_path: None,
801 save_baseline_path: None,
802 production: resolved.production_override.unwrap_or(false),
803 production_override: resolved.production_override,
804 trace: None,
805 changed_since: resolved.changed_since.as_deref(),
806 diff_index: resolved.diff_index(),
807 use_shared_diff_index: false,
808 changed_files: None,
809 workspace: resolved.workspace.as_deref(),
810 changed_workspaces: resolved.changed_workspaces.as_deref(),
811 explain: resolved.explain,
812 explain_skipped: false,
813 summary: false,
814 group_by: None,
815 performance: false,
816 };
817 let result = crate::dupes::execute_dupes(&dupes_options)
818 .map_err(|_| generic_analysis_error("dupes"))?;
819 let mut output = build_duplication_json(
820 &result.report,
821 &result.config.root,
822 result.elapsed,
823 resolved.explain,
824 )
825 .map_err(|err| {
826 ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
827 .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
828 .with_context("dupes")
829 })?;
830 apply_programmatic_envelope_options(&mut output, &resolved);
831 Ok(output)
832 })
833}
834
835fn build_complexity_options<'a>(
836 resolved: &'a ResolvedAnalysisOptions,
837 options: &'a ComplexityOptions,
838) -> HealthOptions<'a> {
839 let state = derived_complexity_options(options);
840
841 HealthOptions {
842 root: &resolved.root,
843 config_path: &resolved.config_path,
844 output: OutputFormat::Human,
845 no_cache: resolved.no_cache,
846 threads: resolved.threads,
847 quiet: true,
848 max_cyclomatic: options.max_cyclomatic,
849 max_cognitive: options.max_cognitive,
850 max_crap: options.max_crap,
851 top: options.top,
852 sort: options.sort.to_cli(),
853 production: resolved.production_override.unwrap_or(false),
854 production_override: resolved.production_override,
855 changed_since: resolved.changed_since.as_deref(),
856 diff_index: resolved.diff_index(),
857 use_shared_diff_index: false,
858 workspace: resolved.workspace.as_deref(),
859 changed_workspaces: resolved.changed_workspaces.as_deref(),
860 baseline: None,
861 save_baseline: None,
862 complexity: state.complexity,
863 complexity_breakdown: false,
864 file_scores: state.file_scores,
865 coverage_gaps: state.coverage_gaps,
866 config_activates_coverage_gaps: !state.any_section,
867 hotspots: state.hotspots,
868 ownership: state.ownership,
869 ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
870 targets: state.targets,
871 css: false,
872 force_full: state.force_full,
873 score_only_output: state.score_only_output,
874 enforce_coverage_gap_gate: true,
875 effort: options.effort.map(TargetEffort::to_cli),
876 score: state.score,
877 min_score: None,
878 since: options.since.as_deref(),
879 min_commits: options.min_commits,
880 explain: resolved.explain,
881 summary: false,
882 save_snapshot: None,
883 trend: false,
884 group_by: None,
885 coverage: options.coverage.as_deref(),
886 coverage_root: options.coverage_root.as_deref(),
887 performance: false,
888 min_severity: None,
889 report_only: false,
890 runtime_coverage: None,
891 churn_file: None,
894 }
895}
896
897struct DerivedComplexityOptions {
898 any_section: bool,
899 complexity: bool,
900 file_scores: bool,
901 coverage_gaps: bool,
902 hotspots: bool,
903 ownership: bool,
904 targets: bool,
905 force_full: bool,
906 score_only_output: bool,
907 score: bool,
908}
909
910fn derived_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
911 let ownership = options.ownership || options.ownership_emails.is_some();
912 let requested_hotspots = options.hotspots || ownership;
913 let requested_targets = options.targets || options.effort.is_some();
914 let any_section = options.complexity
915 || options.file_scores
916 || options.coverage_gaps
917 || requested_hotspots
918 || requested_targets
919 || options.score;
920 let score = if any_section { options.score } else { true };
921 let hotspots = if any_section {
922 requested_hotspots
923 } else {
924 true
925 };
926
927 DerivedComplexityOptions {
928 any_section,
929 complexity: if any_section {
930 options.complexity
931 } else {
932 true
933 },
934 file_scores: effective_file_scores(options, any_section, score),
935 coverage_gaps: if any_section {
936 options.coverage_gaps
937 } else {
938 false
939 },
940 hotspots,
941 ownership: ownership && hotspots,
942 targets: if any_section { requested_targets } else { true },
943 force_full: score,
944 score_only_output: is_score_only_output(options, requested_hotspots, requested_targets),
945 score,
946 }
947}
948
949fn effective_file_scores(options: &ComplexityOptions, any_section: bool, force_full: bool) -> bool {
950 (if any_section {
951 options.file_scores
952 } else {
953 true
954 }) || force_full
955}
956
957fn is_score_only_output(options: &ComplexityOptions, hotspots: bool, targets: bool) -> bool {
958 options.score
959 && !options.complexity
960 && !options.file_scores
961 && !options.coverage_gaps
962 && !hotspots
963 && !targets
964}
965
966pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
968 let resolved = options.analysis.resolve()?;
969 if let Some(path) = &options.coverage
970 && !path.exists()
971 {
972 return Err(ProgrammaticError::new(
973 format!("coverage path does not exist: {}", path.display()),
974 2,
975 )
976 .with_code("FALLOW_INVALID_COVERAGE_PATH")
977 .with_context("health.coverage"));
978 }
979 if let Err(message) =
980 crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
981 {
982 return Err(ProgrammaticError::new(message, 2)
983 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
984 .with_context("health.coverage_root"));
985 }
986
987 resolved.install(|| {
988 let health_options = build_complexity_options(&resolved, options);
989 let result = crate::health::execute_health(&health_options)
990 .map_err(|_| generic_analysis_error("health"))?;
991 let mut output = build_health_json(
992 &result.report,
993 &result.config.root,
994 result.elapsed,
995 resolved.explain,
996 )
997 .map_err(|err| {
998 ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
999 .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
1000 .with_context("health")
1001 })?;
1002 apply_programmatic_envelope_options(&mut output, &resolved);
1003 Ok(output)
1004 })
1005}
1006
1007pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
1009 compute_complexity(options)
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015 use crate::report::test_helpers::sample_results;
1016 use std::process::Command;
1017
1018 const SHARED_DIFF_CHILD_ENV: &str = "FALLOW_PROGRAMMATIC_SHARED_DIFF_CHILD";
1019 const SHARED_DIFF_CHILD_TEST: &str =
1020 "programmatic::tests::programmatic_without_diff_file_ignores_shared_diff_cache";
1021
1022 #[test]
1023 fn circular_dependency_filter_clears_other_issue_types() {
1024 let root = PathBuf::from("/project");
1025 let results = sample_results(&root);
1026 let filtered = filter_for_circular_dependencies(&results);
1027 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
1028 .expect("should serialize");
1029
1030 assert_eq!(json["kind"], "dead-code");
1031 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
1032 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
1033 assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
1034 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
1035 }
1036
1037 #[test]
1038 fn boundary_violation_filter_clears_other_issue_types() {
1039 let root = PathBuf::from("/project");
1040 let results = sample_results(&root);
1041 let filtered = filter_for_boundary_violations(&results);
1042 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
1043 .expect("should serialize");
1044
1045 assert_eq!(json["kind"], "dead-code");
1046 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
1047 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
1048 assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
1049 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
1050 }
1051
1052 #[test]
1053 fn dead_code_without_production_override_uses_per_analysis_config() {
1054 let dir = tempfile::tempdir().expect("temp dir");
1055 let root = dir.path();
1056 std::fs::create_dir_all(root.join("src")).unwrap();
1057 std::fs::write(
1058 root.join("package.json"),
1059 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
1060 )
1061 .unwrap();
1062 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1063 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
1064 std::fs::write(
1065 root.join(".fallowrc.json"),
1066 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
1067 )
1068 .unwrap();
1069
1070 let options = DeadCodeOptions {
1071 analysis: AnalysisOptions {
1072 root: Some(root.to_path_buf()),
1073 ..AnalysisOptions::default()
1074 },
1075 ..DeadCodeOptions::default()
1076 };
1077 let json = detect_dead_code(&options).expect("analysis should succeed");
1078 let paths = unused_file_paths(&json);
1079
1080 assert!(
1081 !paths.iter().any(|path| path.ends_with("utils.test.ts")),
1082 "omitted production option should defer to production.deadCode=true config: {paths:?}"
1083 );
1084 }
1085
1086 #[test]
1087 fn dead_code_legacy_envelope_removes_root_kind() {
1088 let dir = tempfile::tempdir().expect("temp dir");
1089 let root = dir.path();
1090 std::fs::create_dir_all(root.join("src")).unwrap();
1091 std::fs::write(
1092 root.join("package.json"),
1093 r#"{"name":"programmatic-legacy","main":"src/index.ts"}"#,
1094 )
1095 .unwrap();
1096 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1097
1098 let options = DeadCodeOptions {
1099 analysis: AnalysisOptions {
1100 root: Some(root.to_path_buf()),
1101 legacy_envelope: true,
1102 ..AnalysisOptions::default()
1103 },
1104 ..DeadCodeOptions::default()
1105 };
1106 let json = detect_dead_code(&options).expect("analysis should succeed");
1107
1108 assert!(json.get("kind").is_none());
1109 assert_eq!(json["schema_version"], crate::report::SCHEMA_VERSION);
1110 }
1111
1112 #[test]
1113 fn dead_code_explicit_production_false_overrides_config() {
1114 let dir = tempfile::tempdir().expect("temp dir");
1115 let root = dir.path();
1116 std::fs::create_dir_all(root.join("src")).unwrap();
1117 std::fs::write(
1118 root.join("package.json"),
1119 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
1120 )
1121 .unwrap();
1122 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1123 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
1124 std::fs::write(
1125 root.join(".fallowrc.json"),
1126 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
1127 )
1128 .unwrap();
1129
1130 let options = DeadCodeOptions {
1131 analysis: AnalysisOptions {
1132 root: Some(root.to_path_buf()),
1133 production_override: Some(false),
1134 ..AnalysisOptions::default()
1135 },
1136 ..DeadCodeOptions::default()
1137 };
1138 let json = detect_dead_code(&options).expect("analysis should succeed");
1139 let paths = unused_file_paths(&json);
1140
1141 assert!(
1142 paths.iter().any(|path| path.ends_with("utils.test.ts")),
1143 "explicit production=false should include test files despite config: {paths:?}"
1144 );
1145 }
1146
1147 #[test]
1148 fn analysis_resolve_uses_per_call_thread_pool() {
1149 let dir = tempfile::tempdir().expect("temp dir");
1150 let root = dir.path();
1151
1152 let one = AnalysisOptions {
1153 root: Some(root.to_path_buf()),
1154 threads: Some(1),
1155 ..AnalysisOptions::default()
1156 }
1157 .resolve()
1158 .expect("one-thread options should resolve");
1159 let two = AnalysisOptions {
1160 root: Some(root.to_path_buf()),
1161 threads: Some(2),
1162 ..AnalysisOptions::default()
1163 }
1164 .resolve()
1165 .expect("two-thread options should resolve");
1166
1167 assert_eq!(one.install(rayon::current_num_threads), 1);
1168 assert_eq!(two.install(rayon::current_num_threads), 2);
1169 }
1170
1171 #[test]
1172 fn explicit_diff_file_scopes_dead_code_per_call() {
1173 let dir = tempfile::tempdir().expect("temp dir");
1174 let root = dir.path();
1175 std::fs::create_dir_all(root.join("src")).unwrap();
1176 std::fs::write(
1177 root.join("package.json"),
1178 r#"{"name":"programmatic-diff","main":"src/index.ts"}"#,
1179 )
1180 .unwrap();
1181 std::fs::write(
1182 root.join("src/index.ts"),
1183 "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1184 )
1185 .unwrap();
1186 std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1187 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1188 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1189 std::fs::write(
1190 root.join("a.diff"),
1191 diff_for("src/a.ts", "export const deadA = 1;\n"),
1192 )
1193 .unwrap();
1194 std::fs::write(
1195 root.join("b.diff"),
1196 diff_for("src/b.ts", "export const deadB = 1;\n"),
1197 )
1198 .unwrap();
1199
1200 let filters = DeadCodeFilters {
1201 unused_exports: true,
1202 ..DeadCodeFilters::default()
1203 };
1204
1205 let a_json = detect_dead_code(&DeadCodeOptions {
1206 analysis: AnalysisOptions {
1207 root: Some(root.to_path_buf()),
1208 diff_file: Some(PathBuf::from("a.diff")),
1209 ..AnalysisOptions::default()
1210 },
1211 filters: filters.clone(),
1212 ..DeadCodeOptions::default()
1213 })
1214 .expect("a-scoped analysis should succeed");
1215 let b_json = detect_dead_code(&DeadCodeOptions {
1216 analysis: AnalysisOptions {
1217 root: Some(root.to_path_buf()),
1218 diff_file: Some(PathBuf::from("b.diff")),
1219 ..AnalysisOptions::default()
1220 },
1221 filters,
1222 ..DeadCodeOptions::default()
1223 })
1224 .expect("b-scoped analysis should succeed");
1225
1226 assert_eq!(unused_export_names(&a_json), vec!["deadA"]);
1227 assert_eq!(unused_export_names(&b_json), vec!["deadB"]);
1228 }
1229
1230 #[test]
1231 fn programmatic_without_diff_file_ignores_shared_diff_cache() {
1232 if std::env::var_os(SHARED_DIFF_CHILD_ENV).is_some() {
1233 run_programmatic_shared_diff_child();
1234 return;
1235 }
1236
1237 let current_exe = std::env::current_exe().expect("current test binary should be known");
1238 let output = Command::new(current_exe)
1239 .arg("--exact")
1240 .arg(SHARED_DIFF_CHILD_TEST)
1241 .arg("--nocapture")
1242 .env(SHARED_DIFF_CHILD_ENV, "1")
1243 .output()
1244 .expect("shared diff child should start");
1245
1246 assert!(
1247 output.status.success(),
1248 "shared diff child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
1249 output.status.code(),
1250 String::from_utf8_lossy(&output.stdout),
1251 String::from_utf8_lossy(&output.stderr)
1252 );
1253 }
1254
1255 fn run_programmatic_shared_diff_child() {
1256 let dir = tempfile::tempdir().expect("temp dir");
1257 let root = dir.path();
1258 std::fs::create_dir_all(root.join("src")).unwrap();
1259 std::fs::write(
1260 root.join("package.json"),
1261 r#"{"name":"programmatic-shared-diff","main":"src/index.ts"}"#,
1262 )
1263 .unwrap();
1264 std::fs::write(
1265 root.join("src/index.ts"),
1266 "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1267 )
1268 .unwrap();
1269 std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1270 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1271 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1272 std::fs::write(
1273 root.join("a.diff"),
1274 diff_for("src/a.ts", "export const deadA = 1;\n"),
1275 )
1276 .unwrap();
1277
1278 let source = crate::report::ci::diff_filter::DiffSource::Flag(root.join("a.diff"));
1279 let loaded = crate::report::ci::diff_filter::init_shared_diff(Some(&source), true);
1280 assert!(loaded.is_some(), "shared diff should load in child process");
1281
1282 let json = detect_dead_code(&DeadCodeOptions {
1283 analysis: AnalysisOptions {
1284 root: Some(root.to_path_buf()),
1285 ..AnalysisOptions::default()
1286 },
1287 filters: DeadCodeFilters {
1288 unused_exports: true,
1289 ..DeadCodeFilters::default()
1290 },
1291 ..DeadCodeOptions::default()
1292 })
1293 .expect("analysis without explicit diff should succeed");
1294
1295 assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
1296 }
1297
1298 #[test]
1299 fn explicit_diff_file_rejects_stdin_sentinel() {
1300 let dir = tempfile::tempdir().expect("temp dir");
1301 let Err(error) = AnalysisOptions {
1302 root: Some(dir.path().to_path_buf()),
1303 diff_file: Some(PathBuf::from("-")),
1304 ..AnalysisOptions::default()
1305 }
1306 .resolve() else {
1307 panic!("stdin sentinel is not part of the programmatic API");
1308 };
1309
1310 assert_eq!(error.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1311 assert_eq!(error.context.as_deref(), Some("analysis.diffFile"));
1312 }
1313
1314 fn tiny_project() -> tempfile::TempDir {
1316 let dir = tempfile::tempdir().expect("temp dir");
1317 let root = dir.path();
1318 std::fs::create_dir_all(root.join("src")).unwrap();
1319 std::fs::write(
1320 root.join("package.json"),
1321 r#"{"name":"prog-e2e","main":"src/index.ts"}"#,
1322 )
1323 .unwrap();
1324 std::fs::write(
1325 root.join("src/index.ts"),
1326 "export const ok = 1;\nconsole.log(ok);\n",
1327 )
1328 .unwrap();
1329 dir
1330 }
1331
1332 fn analysis_at(root: &Path) -> AnalysisOptions {
1333 AnalysisOptions {
1334 root: Some(root.to_path_buf()),
1335 ..AnalysisOptions::default()
1336 }
1337 }
1338
1339 #[test]
1340 fn resolve_rejects_zero_threads() {
1341 let err = AnalysisOptions {
1342 threads: Some(0),
1343 ..AnalysisOptions::default()
1344 }
1345 .resolve()
1346 .err()
1347 .expect("zero threads must be rejected");
1348 assert_eq!(err.exit_code, 2);
1349 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_THREADS"));
1350 assert_eq!(err.context.as_deref(), Some("analysis.threads"));
1351 }
1352
1353 #[test]
1354 fn resolve_rejects_mutually_exclusive_workspace_flags() {
1355 let err = AnalysisOptions {
1356 workspace: Some(vec!["packages/*".to_owned()]),
1357 changed_workspaces: Some("HEAD~1".to_owned()),
1358 ..AnalysisOptions::default()
1359 }
1360 .resolve()
1361 .err()
1362 .expect("workspace + changed_workspaces must be rejected");
1363 assert_eq!(
1364 err.code.as_deref(),
1365 Some("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
1366 );
1367 assert_eq!(err.context.as_deref(), Some("analysis.workspace"));
1368 }
1369
1370 #[test]
1371 fn resolve_rejects_nonexistent_root() {
1372 let err = AnalysisOptions {
1373 root: Some(PathBuf::from("/definitely/not/a/real/path/xyzzy")),
1374 ..AnalysisOptions::default()
1375 }
1376 .resolve()
1377 .err()
1378 .expect("nonexistent root must be rejected");
1379 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1380 assert_eq!(err.context.as_deref(), Some("analysis.root"));
1381 }
1382
1383 #[test]
1384 fn resolve_rejects_root_that_is_a_file() {
1385 let dir = tempfile::tempdir().expect("temp dir");
1386 let file = dir.path().join("not-a-dir.txt");
1387 std::fs::write(&file, "x").unwrap();
1388 let err = AnalysisOptions {
1389 root: Some(file),
1390 ..AnalysisOptions::default()
1391 }
1392 .resolve()
1393 .err()
1394 .expect("a file root must be rejected");
1395 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1396 }
1397
1398 #[test]
1399 fn resolve_rejects_nonexistent_config_path() {
1400 let dir = tempfile::tempdir().expect("temp dir");
1401 let err = AnalysisOptions {
1402 root: Some(dir.path().to_path_buf()),
1403 config_path: Some(dir.path().join("missing.fallowrc.json")),
1404 ..AnalysisOptions::default()
1405 }
1406 .resolve()
1407 .err()
1408 .expect("nonexistent config must be rejected");
1409 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_CONFIG_PATH"));
1410 assert_eq!(err.context.as_deref(), Some("analysis.configPath"));
1411 }
1412
1413 #[test]
1414 fn resolve_rejects_missing_diff_file() {
1415 let dir = tempfile::tempdir().expect("temp dir");
1416 let err = AnalysisOptions {
1417 root: Some(dir.path().to_path_buf()),
1418 diff_file: Some(PathBuf::from("nope.diff")),
1419 ..AnalysisOptions::default()
1420 }
1421 .resolve()
1422 .err()
1423 .expect("missing diff file must be rejected");
1424 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1425 assert_eq!(err.context.as_deref(), Some("analysis.diffFile"));
1426 }
1427
1428 #[test]
1429 fn resolve_rejects_diff_path_that_is_a_directory() {
1430 let dir = tempfile::tempdir().expect("temp dir");
1431 std::fs::create_dir_all(dir.path().join("a-dir")).unwrap();
1432 let err = AnalysisOptions {
1433 root: Some(dir.path().to_path_buf()),
1434 diff_file: Some(PathBuf::from("a-dir")),
1435 ..AnalysisOptions::default()
1436 }
1437 .resolve()
1438 .err()
1439 .expect("a directory diff path must be rejected");
1440 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1441 }
1442
1443 #[test]
1444 fn detect_circular_dependencies_returns_dead_code_envelope() {
1445 let project = tiny_project();
1446 let json = detect_circular_dependencies(&DeadCodeOptions {
1447 analysis: analysis_at(project.path()),
1448 ..DeadCodeOptions::default()
1449 })
1450 .expect("circular-dependency analysis should succeed");
1451 assert_eq!(json["kind"], "dead-code");
1452 assert!(json["circular_dependencies"].is_array());
1453 }
1454
1455 #[test]
1456 fn detect_boundary_violations_returns_dead_code_envelope() {
1457 let project = tiny_project();
1458 let json = detect_boundary_violations(&DeadCodeOptions {
1459 analysis: analysis_at(project.path()),
1460 ..DeadCodeOptions::default()
1461 })
1462 .expect("boundary-violation analysis should succeed");
1463 assert_eq!(json["kind"], "dead-code");
1464 assert!(json["boundary_violations"].is_array());
1465 }
1466
1467 #[test]
1468 fn detect_boundary_violations_includes_boundary_coverage() {
1469 let project = tiny_project();
1470 let root = project.path();
1471 std::fs::write(
1472 root.join(".fallowrc.json"),
1473 r#"{
1474 "boundaries": {
1475 "zones": [
1476 { "name": "domain", "patterns": ["src/domain/**"] }
1477 ],
1478 "coverage": { "requireAllFiles": true }
1479 }
1480 }"#,
1481 )
1482 .unwrap();
1483
1484 let json = detect_boundary_violations(&DeadCodeOptions {
1485 analysis: analysis_at(root),
1486 ..DeadCodeOptions::default()
1487 })
1488 .expect("boundary-violation analysis should succeed");
1489
1490 let coverage = json["boundary_coverage_violations"]
1491 .as_array()
1492 .expect("coverage findings should be an array");
1493 assert_eq!(coverage.len(), 1);
1494 assert_eq!(coverage[0]["path"], "src/index.ts");
1495 assert_eq!(json["summary"]["boundary_coverage_violations"], 1);
1496 }
1497
1498 #[test]
1499 fn detect_boundary_violations_includes_boundary_calls() {
1500 let project = tiny_project();
1501 let root = project.path();
1502 std::fs::write(
1503 root.join("src/index.ts"),
1504 "console.log('hello');\nexport const x = 1;\n",
1505 )
1506 .unwrap();
1507 std::fs::write(
1508 root.join(".fallowrc.json"),
1509 r#"{
1510 "boundaries": {
1511 "zones": [
1512 { "name": "domain", "patterns": ["src/**"] }
1513 ],
1514 "calls": {
1515 "forbidden": [
1516 { "from": "domain", "callee": "console.*" }
1517 ]
1518 }
1519 }
1520 }"#,
1521 )
1522 .unwrap();
1523
1524 let json = detect_boundary_violations(&DeadCodeOptions {
1525 analysis: analysis_at(root),
1526 ..DeadCodeOptions::default()
1527 })
1528 .expect("boundary-violation analysis should succeed");
1529
1530 let calls = json["boundary_call_violations"]
1531 .as_array()
1532 .expect("boundary call findings should be an array");
1533 assert_eq!(calls.len(), 1);
1534 assert_eq!(calls[0]["path"], "src/index.ts");
1535 assert_eq!(calls[0]["zone"], "domain");
1536 assert_eq!(calls[0]["callee"], "console.log");
1537 assert_eq!(calls[0]["pattern"], "console.*");
1538 assert_eq!(json["summary"]["boundary_call_violations"], 1);
1539 }
1540
1541 #[test]
1542 fn detect_duplication_returns_dupes_envelope() {
1543 let project = tiny_project();
1544 let json = detect_duplication(&DuplicationOptions {
1545 analysis: analysis_at(project.path()),
1546 ..DuplicationOptions::default()
1547 })
1548 .expect("duplication analysis should succeed");
1549 assert_eq!(json["kind"], "dupes");
1550 assert!(json["clone_groups"].is_array());
1552 assert!(json["stats"].is_object());
1553 }
1554
1555 #[test]
1556 fn compute_health_returns_health_envelope() {
1557 let project = tiny_project();
1558 let options = ComplexityOptions {
1559 analysis: analysis_at(project.path()),
1560 ..ComplexityOptions::default()
1561 };
1562 let json = compute_health(&options).expect("health analysis should succeed");
1564 assert_eq!(json["kind"], "health");
1565 assert!(json["summary"].is_object());
1567 assert!(json["findings"].is_array());
1568 }
1569
1570 #[test]
1571 fn compute_complexity_rejects_missing_coverage_path() {
1572 let project = tiny_project();
1573 let err = compute_complexity(&ComplexityOptions {
1574 analysis: analysis_at(project.path()),
1575 coverage: Some(project.path().join("missing-coverage.json")),
1576 ..ComplexityOptions::default()
1577 })
1578 .expect_err("a missing coverage path must be rejected");
1579 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1580 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1581 }
1582
1583 #[test]
1584 fn compute_complexity_rejects_relative_coverage_root() {
1585 let project = tiny_project();
1586 let err = compute_complexity(&ComplexityOptions {
1587 analysis: analysis_at(project.path()),
1588 coverage_root: Some(PathBuf::from("relative/prefix")),
1589 ..ComplexityOptions::default()
1590 })
1591 .expect_err("a relative coverage_root must be rejected");
1592 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1593 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1594 }
1595
1596 #[test]
1597 fn programmatic_error_builders_compose_and_display() {
1598 let err = ProgrammaticError::new("boom", 7)
1599 .with_code("FALLOW_X")
1600 .with_help("try again")
1601 .with_context("ctx.path");
1602 assert_eq!(err.message, "boom");
1603 assert_eq!(err.exit_code, 7);
1604 assert_eq!(err.code.as_deref(), Some("FALLOW_X"));
1605 assert_eq!(err.help.as_deref(), Some("try again"));
1606 assert_eq!(err.context.as_deref(), Some("ctx.path"));
1607 assert_eq!(format!("{err}"), "boom");
1609 }
1610
1611 #[test]
1612 fn generic_analysis_error_uppercases_command_into_code() {
1613 let err = generic_analysis_error("dead-code");
1614 assert_eq!(err.code.as_deref(), Some("FALLOW_DEAD_CODE_FAILED"));
1615 assert_eq!(err.exit_code, 2);
1616 assert_eq!(err.context.as_deref(), Some("fallow dead-code"));
1617 assert!(err.help.is_some(), "diagnostics hint should be attached");
1618 }
1619
1620 fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
1621 json["unused_files"]
1622 .as_array()
1623 .unwrap()
1624 .iter()
1625 .filter_map(|file| file["path"].as_str())
1626 .map(str::to_owned)
1627 .collect()
1628 }
1629
1630 fn unused_export_names(json: &serde_json::Value) -> Vec<String> {
1631 let mut names: Vec<String> = json["unused_exports"]
1632 .as_array()
1633 .unwrap()
1634 .iter()
1635 .filter_map(|export| export["export_name"].as_str())
1636 .map(str::to_owned)
1637 .collect();
1638 names.sort();
1639 names
1640 }
1641
1642 fn diff_for(path: &str, line: &str) -> String {
1643 format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+{line}")
1644 }
1645}