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