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