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