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