Skip to main content

fallow_cli/
programmatic.rs

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