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