Skip to main content

fallow_api/
runtime.rs

1//! Programmatic runtime entry points that do not depend on `fallow-cli`.
2
3use std::path::{Path, PathBuf};
4use std::time::Instant;
5
6use fallow_config::{
7    DetectionMode, DuplicatesConfig, OutputFormat, ProductionAnalysis, WorkspaceInfo,
8};
9use fallow_engine::duplicates::{CloneInstance, DuplicationReport, DuplicationStats};
10use fallow_engine::health::{
11    HealthPipelineInputs, HealthScopeInputs, HealthSeams, RuntimeCoverageSeamInput,
12    execute_health_inner, validate_health_churn_file,
13};
14use fallow_engine::{AnalysisResults, AnalysisSession, ProjectConfig, ProjectConfigOptions};
15use fallow_output::{
16    CHECK_SCHEMA_VERSION, CheckOutput, CheckOutputInput, DeadCodeNextStepsInput, DiffIndex,
17    DupesNextStepsInput, DupesOutput, DupesOutputInput, GroupByMode, HealthGroup, HealthGrouping,
18    HealthJsonOutputInput, HealthOutputInput, HealthReport, MAX_DIFF_BYTES, RootEnvelopeMode,
19    build_check_output, build_dead_code_next_steps, build_dupes_next_steps, build_dupes_output,
20    check_meta, dupes_meta, health_meta, relative_to_diff_path, serialize_check_json_output,
21    serialize_dupes_json_output, strip_root_prefix,
22};
23use fallow_types::workspace::WorkspaceDiagnostic;
24use fallow_types::{output::NextStep, path_util::is_absolute_path_any_platform};
25use globset::Glob;
26use rustc_hash::{FxHashMap, FxHashSet};
27
28use crate::{
29    AnalysisOptions, ComplexityOptions, DeadCodeFilters, DeadCodeOptions, DupesReportPayload,
30    DuplicationMode, DuplicationOptions, ProgrammaticError,
31};
32
33const SCHEMA_VERSION: u32 = 1;
34const HEALTH_SCHEMA_VERSION: u32 = 7;
35
36type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
37
38/// Inputs for serializing health JSON output through the API boundary.
39pub struct HealthJsonReportInput<'a> {
40    pub report: HealthReport,
41    pub root: &'a Path,
42    pub elapsed: std::time::Duration,
43    pub explain: bool,
44    pub grouped_by: Option<GroupByMode>,
45    pub groups: Option<Vec<HealthGroup>>,
46    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
47    pub next_steps: Vec<NextStep>,
48    pub envelope_mode: RootEnvelopeMode,
49    pub telemetry_analysis_run_id: Option<&'a str>,
50}
51
52/// Runtime probes used by programmatic health output assembly.
53///
54/// Concrete runners supply environment and project facts while the stable
55/// command strings and output ordering remain owned by `fallow-output`.
56pub struct ProgrammaticHealthNextStepFacts {
57    pub suggestions_enabled: bool,
58    pub offer_setup: bool,
59    pub impact_digest: Option<fallow_output::ImpactDigestCounts>,
60    pub audit_changed: bool,
61}
62
63/// Health runner output shared by API, NAPI, and compatibility adapters.
64///
65/// The analysis payload is a typed engine result. Runtime-only presentation
66/// probes stay explicit so the API boundary, not the concrete runner, owns the
67/// final programmatic report assembly.
68pub struct ProgrammaticHealthRun {
69    pub analysis: fallow_engine::HealthAnalysisResult,
70    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
71    pub next_step_facts: ProgrammaticHealthNextStepFacts,
72    pub telemetry_analysis_run_id: Option<String>,
73}
74
75/// Temporary runner boundary for programmatic health while execution moves from
76/// the CLI crate into the engine/API stack.
77pub trait ProgrammaticHealthRunner {
78    /// Run health analysis for public programmatic options.
79    ///
80    /// # Errors
81    ///
82    /// Returns a structured programmatic error when the concrete runner cannot
83    /// resolve options or complete health analysis.
84    fn run_programmatic_health(
85        &self,
86        options: &ComplexityOptions,
87    ) -> Result<ProgrammaticHealthRun, ProgrammaticError>;
88}
89
90/// Default health runner backed directly by `fallow-engine`.
91///
92/// This runs the command-neutral health pipeline through
93/// [`execute_health_inner`] without touching the CLI crate: the programmatic
94/// path never groups (`--group-by`), never drives the runtime coverage sidecar,
95/// and never records CLI telemetry, so the seams are inert no-ops. NAPI and
96/// future Rust embedders use this runner; the CLI keeps its own runner for the
97/// `fallow health` command path.
98#[derive(Debug, Clone, Copy, Default)]
99pub struct EngineHealthRunner;
100
101impl ProgrammaticHealthRunner for EngineHealthRunner {
102    fn run_programmatic_health(
103        &self,
104        options: &ComplexityOptions,
105    ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
106        let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
107        resolved.install(|| run_programmatic_health_on_engine(&resolved, options))
108    }
109}
110
111/// The runtime coverage seam is never reached on the programmatic path
112/// (`runtime_coverage` is always `None`), so the analyzer is an unreachable
113/// guard rather than a real sidecar driver.
114fn programmatic_runtime_coverage_seam(
115    _options: &fallow_engine::RuntimeCoverageOptions,
116    _input: RuntimeCoverageSeamInput<'_>,
117) -> Result<fallow_output::RuntimeCoverageReport, std::process::ExitCode> {
118    Err(std::process::ExitCode::from(2))
119}
120
121fn run_programmatic_health_on_engine(
122    resolved: &ProgrammaticAnalysisContext,
123    options: &ComplexityOptions,
124) -> ProgrammaticResult<ProgrammaticHealthRun> {
125    let health_options = derive_programmatic_health_execution_options(resolved, options);
126
127    validate_health_churn_file(&health_options).map_err(|_| generic_health_error("health"))?;
128
129    let start = Instant::now();
130    let project_config = fallow_engine::config_for_project_analysis(
131        &resolved.root,
132        resolved.config_path.as_deref(),
133        ProjectConfigOptions {
134            output: OutputFormat::Human,
135            no_cache: resolved.no_cache,
136            threads: resolved.threads,
137            production_override: resolved.production_override,
138            quiet: true,
139            analysis: ProductionAnalysis::Health,
140        },
141    )
142    .map_err(|err| {
143        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
144            .with_code("FALLOW_CONFIG_LOAD_FAILED")
145            .with_context("analysis.configPath")
146    })?;
147    let config_ms = start.elapsed().as_secs_f64() * 1000.0;
148
149    let session = AnalysisSession::from_config(project_config);
150    stash_workspace_diagnostics_for_session(&session);
151    let parts = session.into_parts();
152    let config = parts.config;
153    let files = parts.files;
154
155    let parse_start = Instant::now();
156    let cache = if config.no_cache {
157        None
158    } else {
159        fallow_engine::cache::CacheStore::load(
160            &config.cache_dir,
161            config.cache_config_hash,
162            fallow_engine::resolve_cache_max_size_bytes(&config),
163        )
164    };
165    let parse_result = fallow_engine::extract::parse_all_files(&files, cache.as_ref(), true);
166    let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
167    let parse_cpu_ms = parse_result.parse_cpu_ms;
168
169    let scope_inputs = HealthScopeInputs::<fallow_engine::health::NoGroupResolver> {
170        changed_files: resolved
171            .changed_since
172            .as_deref()
173            .and_then(|git_ref| fallow_engine::changed_files(&resolved.root, git_ref).ok()),
174        diff_index: resolved.diff.as_ref(),
175        ws_roots: resolved.workspace_roots.clone(),
176        group_resolver: None,
177    };
178    let seams = HealthSeams {
179        runtime_coverage_analyzer: &programmatic_runtime_coverage_seam,
180        note_graph_structure: &|_module_count, _edge_count| {},
181    };
182
183    let result = execute_health_inner(
184        &health_options,
185        HealthPipelineInputs {
186            config,
187            files,
188            modules: parse_result.modules,
189            config_ms,
190            discover_ms: 0.0,
191            parse_ms,
192            parse_cpu_ms,
193            shared_parse: false,
194            pre_computed_analysis: None,
195        },
196        scope_inputs,
197        &seams,
198    )
199    .map_err(|_| generic_health_error("health"))?;
200
201    let root = result.config.root.clone();
202    let next_step_facts = ProgrammaticHealthNextStepFacts {
203        suggestions_enabled: suggestions_enabled(),
204        offer_setup: setup_pointer_applicable(&root),
205        impact_digest: None,
206        audit_changed: fallow_engine::churn::is_git_repo(&root),
207    };
208    Ok(ProgrammaticHealthRun {
209        analysis: result.without_group_resolver(),
210        workspace_diagnostics: fallow_config::workspace_diagnostics_for(&root),
211        next_step_facts,
212        telemetry_analysis_run_id: None,
213    })
214}
215
216fn generic_health_error(command: &str) -> ProgrammaticError {
217    let code = format!(
218        "FALLOW_{}_FAILED",
219        command.replace('-', "_").to_ascii_uppercase()
220    );
221    ProgrammaticError::new(format!("{command} failed"), 2)
222        .with_code(code)
223        .with_context(format!("fallow {command}"))
224        .with_help(format!(
225            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
226        ))
227}
228
229/// Run programmatic health / complexity through the engine-backed runner.
230///
231/// # Errors
232///
233/// Returns a structured programmatic error for invalid options or analysis
234/// failures.
235pub fn run_health(options: &ComplexityOptions) -> ProgrammaticResult<HealthProgrammaticOutput> {
236    run_health_with_runner(options, &EngineHealthRunner)
237}
238
239/// Run programmatic health / complexity and return the stable JSON contract.
240///
241/// # Errors
242///
243/// Returns a structured programmatic error for invalid options, analysis
244/// failures, or output serialization failures.
245pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
246    run_health(options)?.into_json()
247}
248
249/// Derive engine-owned health execution options from public programmatic API
250/// options and a resolved analysis context.
251///
252/// This keeps option interpretation at the API boundary while concrete runners
253/// focus on executing the health pipeline.
254#[must_use]
255pub fn derive_programmatic_health_execution_options<'a>(
256    resolved: &'a ProgrammaticAnalysisContext,
257    options: &'a ComplexityOptions,
258) -> fallow_engine::HealthExecutionOptions<'a> {
259    let run = crate::derive_complexity_run_options(options);
260
261    fallow_engine::HealthExecutionOptions {
262        root: resolved.root(),
263        config_path: resolved.config_path(),
264        output: OutputFormat::Human,
265        no_cache: resolved.no_cache(),
266        threads: resolved.threads(),
267        quiet: true,
268        complexity_breakdown: false,
269        thresholds: run.thresholds,
270        top: run.top,
271        sort: run.sort,
272        production: resolved.production_override().unwrap_or(false),
273        production_override: resolved.production_override(),
274        changed_since: resolved.changed_since(),
275        diff_index: resolved.diff_index(),
276        use_shared_diff_index: false,
277        workspace: resolved.workspace(),
278        changed_workspaces: resolved.changed_workspaces(),
279        baseline: None,
280        save_baseline: None,
281        complexity: run.sections.complexity,
282        file_scores: run.sections.file_scores,
283        coverage_gaps: run.sections.coverage_gaps,
284        config_activates_coverage_gaps: !run.sections.any_section,
285        hotspots: run.sections.hotspots,
286        ownership: run.sections.ownership,
287        ownership_emails: run.ownership_emails,
288        targets: run.sections.targets,
289        css: run.css,
290        force_full: run.sections.force_full,
291        score_only_output: run.sections.score_only_output,
292        enforce_coverage_gap_gate: true,
293        effort: run.effort,
294        score: run.sections.score,
295        gates: fallow_engine::HealthGateOptions::default(),
296        since: run.since,
297        min_commits: run.min_commits,
298        explain: resolved.explain_enabled(),
299        summary: false,
300        save_snapshot: None,
301        trend: false,
302        coverage_inputs: run.coverage_inputs,
303        performance: false,
304        runtime_coverage: None,
305        churn_file: None,
306        group_by: None,
307    }
308}
309
310/// Resolved common programmatic analysis context.
311///
312/// This owns validation, root/config/diff resolution, production overrides,
313/// workspace scope, and the per-call thread pool shared by programmatic
314/// analysis families. API runtimes and engine-backed runners use it directly.
315pub struct ProgrammaticAnalysisContext {
316    root: PathBuf,
317    config_path: Option<PathBuf>,
318    no_cache: bool,
319    threads: usize,
320    pool: rayon::ThreadPool,
321    diff: Option<DiffIndex>,
322    production_override: Option<bool>,
323    changed_since: Option<String>,
324    workspace: Option<Vec<String>>,
325    changed_workspaces: Option<String>,
326    workspace_roots: Option<Vec<PathBuf>>,
327    legacy_envelope: bool,
328    explain: bool,
329}
330
331/// Typed programmatic dead-code output before JSON serialization.
332///
333/// This is the API boundary embedders should use when they need access to the
334/// typed engine/output result. The `detect_*` helpers remain as JSON
335/// compatibility shims over this type.
336#[derive(Debug, Clone)]
337pub struct DeadCodeProgrammaticOutput {
338    pub output: CheckOutput,
339    pub root: PathBuf,
340    pub envelope_mode: RootEnvelopeMode,
341    pub telemetry_analysis_run_id: Option<String>,
342}
343
344impl DeadCodeProgrammaticOutput {
345    /// Serialize the typed programmatic result into the stable JSON contract.
346    ///
347    /// # Errors
348    ///
349    /// Returns a structured error if the output contract cannot be serialized.
350    pub fn into_json(self) -> ProgrammaticResult<serde_json::Value> {
351        let Self {
352            output,
353            root,
354            envelope_mode,
355            telemetry_analysis_run_id,
356        } = self;
357        let mut json = serialize_check_json_output(
358            output,
359            envelope_mode,
360            telemetry_analysis_run_id.as_deref(),
361        )
362        .map_err(|err| {
363            ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
364                .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
365                .with_context("dead-code")
366        })?;
367        let root_prefix = format!("{}/", root.display());
368        strip_root_prefix(&mut json, &root_prefix);
369        Ok(json)
370    }
371}
372
373/// Typed programmatic duplication output before JSON serialization.
374#[derive(Debug, Clone)]
375pub struct DuplicationProgrammaticOutput {
376    pub output: DupesOutput<DupesReportPayload, serde_json::Value>,
377    pub root: PathBuf,
378    pub envelope_mode: RootEnvelopeMode,
379    pub telemetry_analysis_run_id: Option<String>,
380}
381
382impl DuplicationProgrammaticOutput {
383    /// Serialize the typed programmatic result into the stable JSON contract.
384    ///
385    /// # Errors
386    ///
387    /// Returns a structured error if the output contract cannot be serialized.
388    pub fn into_json(self) -> ProgrammaticResult<serde_json::Value> {
389        let Self {
390            output,
391            root,
392            envelope_mode,
393            telemetry_analysis_run_id,
394        } = self;
395        let mut json = serialize_dupes_json_output(
396            output,
397            envelope_mode,
398            telemetry_analysis_run_id.as_deref(),
399        )
400        .map_err(|err| {
401            ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
402                .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
403                .with_context("dupes")
404        })?;
405        let root_prefix = format!("{}/", root.display());
406        strip_root_prefix(&mut json, &root_prefix);
407        Ok(json)
408    }
409}
410
411/// Typed programmatic health / complexity output before JSON serialization.
412#[derive(Debug, Clone)]
413pub struct HealthProgrammaticOutput {
414    pub report: HealthReport,
415    pub grouping: Option<HealthGrouping>,
416    pub root: PathBuf,
417    pub elapsed: std::time::Duration,
418    pub explain: bool,
419    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
420    pub next_steps: Vec<NextStep>,
421    pub envelope_mode: RootEnvelopeMode,
422    pub telemetry_analysis_run_id: Option<String>,
423}
424
425impl HealthProgrammaticOutput {
426    /// Serialize the typed programmatic result into the stable JSON contract.
427    ///
428    /// # Errors
429    ///
430    /// Returns a structured error if the health output contract cannot be
431    /// serialized.
432    pub fn into_json(self) -> ProgrammaticResult<serde_json::Value> {
433        let Self {
434            report,
435            grouping,
436            root,
437            elapsed,
438            explain,
439            workspace_diagnostics,
440            next_steps,
441            envelope_mode,
442            telemetry_analysis_run_id,
443        } = self;
444        let (grouped_by, groups) = grouping.map_or((None, None), |grouping| {
445            (
446                group_by_mode_from_label(grouping.mode),
447                Some(grouping.groups),
448            )
449        });
450        serialize_health_report_json(HealthJsonReportInput {
451            report,
452            root: &root,
453            elapsed,
454            explain,
455            grouped_by,
456            groups,
457            workspace_diagnostics,
458            next_steps,
459            envelope_mode,
460            telemetry_analysis_run_id: telemetry_analysis_run_id.as_deref(),
461        })
462        .map_err(|err| {
463            ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
464                .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
465                .with_context("health")
466        })
467    }
468}
469
470/// Run duplication analysis and return the JSON output contract.
471///
472/// This is the first runtime path owned by `fallow-api` instead of the CLI
473/// crate. It intentionally returns the same root JSON shape that embedders
474/// already receive from `fallow-node`.
475///
476/// # Errors
477///
478/// Returns a structured programmatic error for invalid options, config load
479/// failures, git changed-file failures, or serialization failures.
480pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
481    run_duplication(options)?.into_json()
482}
483
484/// Run duplication analysis and return typed API output before serialization.
485///
486/// # Errors
487///
488/// Returns a structured programmatic error for invalid options, config load
489/// failures, or git changed-file failures.
490pub fn run_duplication(
491    options: &DuplicationOptions,
492) -> ProgrammaticResult<DuplicationProgrammaticOutput> {
493    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
494    resolved.install(|| detect_duplication_inner(options, &resolved))
495}
496
497/// Run dead-code analysis and return the JSON output contract.
498///
499/// This runtime path is owned by `fallow-api` and uses the typed engine plus
500/// output crates directly.
501///
502/// # Errors
503///
504/// Returns a structured programmatic error for unsupported options, invalid
505/// options, config load failures, analysis failures, git changed-file failures,
506/// or serialization failures.
507pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
508    run_dead_code(options)?.into_json()
509}
510
511/// Run dead-code analysis and return typed API output before serialization.
512///
513/// # Errors
514///
515/// Returns a structured programmatic error for unsupported options, invalid
516/// options, config load failures, analysis failures, or git changed-file
517/// failures.
518pub fn run_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
519    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
520    resolved.install(|| detect_dead_code_inner(options, &resolved, |_| {}))
521}
522
523/// Run circular-dependency analysis and return the dead-code JSON envelope.
524///
525/// This is a convenience wrapper over the typed dead-code runtime. It keeps the
526/// envelope shape stable while narrowing results to `circular_dependencies`.
527///
528/// # Errors
529///
530/// Returns the same structured errors as [`detect_dead_code`].
531pub fn detect_circular_dependencies(
532    options: &DeadCodeOptions,
533) -> ProgrammaticResult<serde_json::Value> {
534    run_circular_dependencies(options)?.into_json()
535}
536
537/// Run circular-dependency analysis and return typed API output before JSON.
538///
539/// # Errors
540///
541/// Returns the same structured errors as [`run_dead_code`].
542pub fn run_circular_dependencies(
543    options: &DeadCodeOptions,
544) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
545    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
546    resolved.install(|| detect_dead_code_inner(options, &resolved, keep_circular_dependencies))
547}
548
549/// Run boundary-family analysis and return the dead-code JSON envelope.
550///
551/// This is a convenience wrapper over the typed dead-code runtime. It keeps
552/// `boundary_violations`, `boundary_coverage_violations`, and
553/// `boundary_call_violations`.
554///
555/// # Errors
556///
557/// Returns the same structured errors as [`detect_dead_code`].
558pub fn detect_boundary_violations(
559    options: &DeadCodeOptions,
560) -> ProgrammaticResult<serde_json::Value> {
561    run_boundary_violations(options)?.into_json()
562}
563
564/// Run boundary-family analysis and return typed API output before JSON.
565///
566/// # Errors
567///
568/// Returns the same structured errors as [`run_dead_code`].
569pub fn run_boundary_violations(
570    options: &DeadCodeOptions,
571) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
572    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
573    resolved.install(|| detect_dead_code_inner(options, &resolved, keep_boundary_violations))
574}
575
576/// Serialize a health / complexity report into the stable JSON output contract.
577///
578/// The health runner is still migrating out of the CLI crate, so callers pass
579/// the already assembled report plus CLI-owned suggestion and workspace
580/// diagnostics policy as explicit typed inputs.
581///
582/// # Errors
583///
584/// Returns a serde error when the report cannot be converted to JSON.
585pub fn serialize_health_report_json(
586    input: HealthJsonReportInput<'_>,
587) -> Result<serde_json::Value, serde_json::Error> {
588    let root_prefix = format!("{}/", input.root.display());
589    fallow_output::serialize_health_json_output(HealthJsonOutputInput {
590        output: HealthOutputInput {
591            schema_version: HEALTH_SCHEMA_VERSION,
592            version: env!("CARGO_PKG_VERSION").to_string(),
593            elapsed: input.elapsed,
594            report: input.report,
595            grouped_by: input.grouped_by,
596            groups: input.groups,
597            meta: input.explain.then(health_meta),
598            workspace_diagnostics: input.workspace_diagnostics,
599            next_steps: input.next_steps,
600        },
601        root_prefix: Some(&root_prefix),
602        envelope_mode: input.envelope_mode,
603        analysis_run_id: input.telemetry_analysis_run_id,
604    })
605}
606
607/// Run programmatic health / complexity through the API-owned output boundary.
608///
609/// The concrete runner is injected while the health implementation is still
610/// being migrated out of the CLI crate. Runner-owned responsibilities are
611/// limited to typed analysis plus runtime facts; this API crate owns the final
612/// JSON contract assembly.
613///
614/// # Errors
615///
616/// Returns a structured programmatic error for invalid options, runner
617/// failures, or output serialization failures.
618pub fn compute_complexity_with_runner(
619    options: &ComplexityOptions,
620    runner: &impl ProgrammaticHealthRunner,
621) -> ProgrammaticResult<serde_json::Value> {
622    run_complexity_with_runner(options, runner)?.into_json()
623}
624
625/// Run programmatic health / complexity and return typed API output.
626///
627/// The concrete runner is injected while the health implementation is still
628/// being migrated out of the CLI crate. Runner-owned responsibilities are
629/// limited to typed analysis plus runtime facts; this API crate owns the final
630/// programmatic report assembly.
631///
632/// # Errors
633///
634/// Returns a structured programmatic error for invalid options or runner
635/// failures.
636pub fn run_complexity_with_runner(
637    options: &ComplexityOptions,
638    runner: &impl ProgrammaticHealthRunner,
639) -> ProgrammaticResult<HealthProgrammaticOutput> {
640    crate::validate_complexity_options(options)?;
641    let ProgrammaticHealthRun {
642        analysis,
643        workspace_diagnostics,
644        next_step_facts,
645        telemetry_analysis_run_id,
646    } = runner.run_programmatic_health(options)?;
647    let root = analysis.config.root.clone();
648    let next_steps =
649        fallow_output::build_health_next_steps(fallow_output::build_health_next_steps_input(
650            &analysis.report,
651            next_step_facts.suggestions_enabled,
652            next_step_facts.offer_setup,
653            next_step_facts.impact_digest,
654            next_step_facts.audit_changed,
655        ));
656    Ok(HealthProgrammaticOutput {
657        report: analysis.report,
658        grouping: analysis.grouping,
659        root,
660        elapsed: analysis.elapsed,
661        explain: options.analysis.explain,
662        workspace_diagnostics,
663        next_steps,
664        envelope_mode: root_envelope_mode(options.analysis.legacy_envelope),
665        telemetry_analysis_run_id,
666    })
667}
668
669/// Alias for [`compute_complexity_with_runner`] with a product-oriented name.
670///
671/// # Errors
672///
673/// Returns the same structured errors as [`compute_complexity_with_runner`].
674pub fn compute_health_with_runner(
675    options: &ComplexityOptions,
676    runner: &impl ProgrammaticHealthRunner,
677) -> ProgrammaticResult<serde_json::Value> {
678    run_health_with_runner(options, runner)?.into_json()
679}
680
681/// Alias for [`run_complexity_with_runner`] with a product-oriented name.
682///
683/// # Errors
684///
685/// Returns the same structured errors as [`run_complexity_with_runner`].
686pub fn run_health_with_runner(
687    options: &ComplexityOptions,
688    runner: &impl ProgrammaticHealthRunner,
689) -> ProgrammaticResult<HealthProgrammaticOutput> {
690    run_complexity_with_runner(options, runner)
691}
692
693fn group_by_mode_from_label(label: &str) -> Option<GroupByMode> {
694    match label {
695        "owner" => Some(GroupByMode::Owner),
696        "directory" => Some(GroupByMode::Directory),
697        "package" => Some(GroupByMode::Package),
698        "section" => Some(GroupByMode::Section),
699        _ => None,
700    }
701}
702
703// ---------------------------------------------------------------------------
704// Next-steps runtime probes for the programmatic / napi surface.
705//
706// The pure builders live in `fallow-output`; the env/fs/git probes the CLI
707// keeps in `report::suggestions` are mirrored here for the api boundary, which
708// cannot depend on `fallow-cli`. The `impact_digest` is deliberately `None`:
709// the Fallow Impact store is a CLI-owned, developer-local opt-in the api crate
710// has no access to, and it only ever rides an otherwise-clean run.
711// ---------------------------------------------------------------------------
712
713/// `FALLOW_SUGGESTIONS=off` (or `0`/`false`/`no`/`disabled`) disables the
714/// `next_steps[]` array. Mirrors `report::suggestions::suggestions_enabled`.
715fn suggestions_enabled() -> bool {
716    match std::env::var("FALLOW_SUGGESTIONS").ok().as_deref() {
717        Some(raw) => !matches!(
718            raw.trim().to_ascii_lowercase().as_str(),
719            "off" | "0" | "false" | "no" | "disabled"
720        ),
721        None => true,
722    }
723}
724
725fn is_ci() -> bool {
726    std::env::var_os("CI").is_some()
727        || std::env::var_os("GITHUB_ACTIONS").is_some()
728        || std::env::var_os("GITLAB_CI").is_some()
729}
730
731/// First-contact `setup` next-step gate: no fallow config up to the repo root
732/// and not running in CI. The CLI additionally consults the impact store for a
733/// declined-onboarding flag; that store is CLI-owned, so the api surface omits
734/// it (an embedder that wants the prompt suppressed sets `FALLOW_SUGGESTIONS`).
735fn setup_pointer_applicable(root: &Path) -> bool {
736    root.exists() && fallow_config::FallowConfig::find_config_path(root).is_none() && !is_ci()
737}
738
739/// Resolve a concrete `--changed-workspaces` ref for the `scope-workspaces`
740/// next step, or `None` when there are no workspaces / no resolvable ref (in
741/// which case the step is omitted rather than shipping an unrunnable guess).
742fn default_workspace_ref(root: &Path) -> Option<String> {
743    if fallow_config::discover_workspaces(root).is_empty() {
744        return None;
745    }
746    if let Some(reference) = run_git(
747        root,
748        &[
749            "symbolic-ref",
750            "--quiet",
751            "--short",
752            "refs/remotes/origin/HEAD",
753        ],
754    ) {
755        let reference = reference.trim();
756        if !reference.is_empty() {
757            return Some(reference.to_string());
758        }
759    }
760    ["origin/main", "origin/master"]
761        .into_iter()
762        .find(|candidate| git_ref_exists(root, candidate))
763        .map(str::to_string)
764}
765
766fn git_ref_exists(root: &Path, reference: &str) -> bool {
767    std::process::Command::new("git")
768        .arg("-C")
769        .arg(root)
770        .args(["rev-parse", "--verify", "--quiet", reference])
771        .output()
772        .is_ok_and(|output| output.status.success())
773}
774
775fn run_git(root: &Path, args: &[&str]) -> Option<String> {
776    let output = std::process::Command::new("git")
777        .arg("-C")
778        .arg(root)
779        .args(args)
780        .output()
781        .ok()?;
782    if !output.status.success() {
783        return None;
784    }
785    String::from_utf8(output.stdout).ok()
786}
787
788/// Discover and stash workspace-discovery diagnostics for `root` so the
789/// programmatic / napi serializers can read them back via
790/// [`fallow_config::workspace_diagnostics_for`]. The CLI does this in its
791/// `load_config_for_analysis` (`runtime_support::report_workspace_diagnostics`);
792/// the engine-backed config load the api crate uses does not, so without this
793/// the `workspace_diagnostics[]` array would be empty even when the CLI emits
794/// it. Best-effort: a discovery error leaves the registry untouched rather than
795/// failing the analysis.
796fn stash_workspace_diagnostics_for_session(session: &AnalysisSession) {
797    let root = session.root();
798    if let Ok((_, diagnostics)) =
799        fallow_config::discover_workspaces_with_diagnostics(root, &session.config().ignore_patterns)
800    {
801        fallow_config::stash_workspace_diagnostics(root, diagnostics);
802    }
803}
804
805fn detect_dead_code_inner(
806    options: &DeadCodeOptions,
807    resolved: &ProgrammaticAnalysisContext,
808    post_filter: impl FnOnce(&mut AnalysisResults),
809) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
810    let start = Instant::now();
811    let session = load_dead_code_session(options, resolved)?;
812    stash_workspace_diagnostics_for_session(&session);
813    let analysis = session.analyze_dead_code().map_err(|err| {
814        ProgrammaticError::new(format!("dead-code analysis failed: {err}"), 2)
815            .with_code("FALLOW_DEAD_CODE_FAILED")
816            .with_context("dead-code")
817    })?;
818    let mut results = analysis.results;
819
820    apply_dead_code_scope(options, resolved, &session, &mut results)?;
821    apply_dead_code_filters(&options.filters, &mut results);
822    post_filter(&mut results);
823
824    let root = session.root();
825    let next_steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
826        suggestions_enabled: suggestions_enabled(),
827        results: &results,
828        root,
829        offer_setup: setup_pointer_applicable(root),
830        impact_digest: None,
831        workspace_ref: default_workspace_ref(root).as_deref(),
832        audit_changed: fallow_engine::churn::is_git_repo(root),
833    });
834    let output = build_check_output(CheckOutputInput {
835        schema_version: CHECK_SCHEMA_VERSION,
836        version: env!("CARGO_PKG_VERSION").to_string(),
837        elapsed: start.elapsed(),
838        results,
839        config_fixable: fallow_config::is_config_fixable(
840            &resolved.root,
841            resolved.config_path.as_ref(),
842        ),
843        meta: options.analysis.explain.then(check_meta),
844        workspace_diagnostics: fallow_config::workspace_diagnostics_for(root),
845        next_steps,
846    });
847    Ok(DeadCodeProgrammaticOutput {
848        output,
849        root: session.root().to_path_buf(),
850        envelope_mode: root_envelope_mode(resolved.legacy_envelope),
851        telemetry_analysis_run_id: None,
852    })
853}
854
855fn keep_circular_dependencies(results: &mut AnalysisResults) {
856    let entry_point_summary = results.entry_point_summary.take();
857    let circular_dependencies = std::mem::take(&mut results.circular_dependencies);
858    *results = AnalysisResults::default();
859    results.entry_point_summary = entry_point_summary;
860    results.circular_dependencies = circular_dependencies;
861}
862
863fn keep_boundary_violations(results: &mut AnalysisResults) {
864    let entry_point_summary = results.entry_point_summary.take();
865    let boundary_violations = std::mem::take(&mut results.boundary_violations);
866    let boundary_coverage_violations = std::mem::take(&mut results.boundary_coverage_violations);
867    let boundary_call_violations = std::mem::take(&mut results.boundary_call_violations);
868    *results = AnalysisResults::default();
869    results.entry_point_summary = entry_point_summary;
870    results.boundary_violations = boundary_violations;
871    results.boundary_coverage_violations = boundary_coverage_violations;
872    results.boundary_call_violations = boundary_call_violations;
873}
874
875fn load_dead_code_session(
876    options: &DeadCodeOptions,
877    resolved: &ProgrammaticAnalysisContext,
878) -> ProgrammaticResult<AnalysisSession> {
879    let project_config = fallow_engine::config_for_project_analysis(
880        &resolved.root,
881        resolved.config_path.as_deref(),
882        ProjectConfigOptions {
883            output: OutputFormat::Json,
884            no_cache: resolved.no_cache,
885            threads: resolved.threads,
886            production_override: resolved.production_override,
887            quiet: true,
888            analysis: ProductionAnalysis::DeadCode,
889        },
890    )
891    .map_err(|err| {
892        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
893            .with_code("FALLOW_CONFIG_LOAD_FAILED")
894            .with_context("analysis.configPath")
895    })?;
896    let project_config = configure_project_for_dead_code(project_config, options);
897    Ok(AnalysisSession::from_config(project_config))
898}
899
900fn configure_project_for_dead_code(
901    mut project_config: ProjectConfig,
902    options: &DeadCodeOptions,
903) -> ProjectConfig {
904    if options.include_entry_exports {
905        project_config.config.include_entry_exports = true;
906    }
907    activate_explicit_dead_code_opt_ins(&options.filters, &mut project_config.config.rules);
908    project_config
909}
910
911fn activate_explicit_dead_code_opt_ins(
912    filters: &DeadCodeFilters,
913    rules: &mut fallow_config::RulesConfig,
914) {
915    if filters.private_type_leaks && rules.private_type_leaks == fallow_config::Severity::Off {
916        rules.private_type_leaks = fallow_config::Severity::Warn;
917    }
918}
919
920fn apply_dead_code_scope(
921    options: &DeadCodeOptions,
922    resolved: &ProgrammaticAnalysisContext,
923    session: &AnalysisSession,
924    results: &mut AnalysisResults,
925) -> ProgrammaticResult<()> {
926    if let Some(workspace_roots) = resolved.workspace_roots.as_ref() {
927        fallow_engine::dead_code::filter_to_workspaces(results, workspace_roots);
928    }
929    if let Some(changed_files) = changed_files_for_run(resolved)? {
930        fallow_engine::dead_code::filter_by_changed_files(results, &changed_files);
931    }
932    if let Some(diff) = resolved.diff.as_ref() {
933        filter_dead_code_by_diff(results, diff, session.root());
934    }
935    apply_dead_code_file_filter(options, session.root(), results);
936    Ok(())
937}
938
939fn filter_dead_code_by_diff(results: &mut AnalysisResults, diff: &DiffIndex, root: &Path) {
940    let touches_file = |path: &Path| -> bool {
941        relative_to_diff_path(path, root).is_none_or(|rel| diff.touches_file(&rel))
942    };
943    let line_in_diff = |path: &Path, line: u32| -> bool {
944        relative_to_diff_path(path, root)
945            .is_none_or(|rel| diff.line_is_added(&rel, u64::from(line)))
946    };
947
948    filter_dead_code_source_findings(results, &touches_file, &line_in_diff);
949    filter_dead_code_security_findings(results, &touches_file, &line_in_diff);
950    filter_dead_code_dependency_findings(results, &line_in_diff);
951    filter_dead_code_graph_findings(results, &touches_file, &line_in_diff);
952    filter_dead_code_framework_findings(results, &line_in_diff);
953}
954
955fn filter_dead_code_source_findings(
956    results: &mut AnalysisResults,
957    touches_file: &dyn Fn(&Path) -> bool,
958    line_in_diff: &dyn Fn(&Path, u32) -> bool,
959) {
960    results
961        .unused_files
962        .retain(|finding| touches_file(&finding.file.path));
963    results
964        .unused_exports
965        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
966    results
967        .unused_types
968        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
969    results
970        .private_type_leaks
971        .retain(|finding| line_in_diff(&finding.leak.path, finding.leak.line));
972    results
973        .unused_enum_members
974        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
975    results
976        .unused_class_members
977        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
978    results
979        .unused_store_members
980        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
981    results
982        .unprovided_injects
983        .retain(|finding| line_in_diff(&finding.inject.path, finding.inject.line));
984    results
985        .unrendered_components
986        .retain(|finding| line_in_diff(&finding.component.path, finding.component.line));
987    results
988        .unused_component_props
989        .retain(|finding| line_in_diff(&finding.prop.path, finding.prop.line));
990    results
991        .unused_component_emits
992        .retain(|finding| line_in_diff(&finding.emit.path, finding.emit.line));
993    results
994        .unused_component_inputs
995        .retain(|finding| line_in_diff(&finding.input.path, finding.input.line));
996    results
997        .unused_component_outputs
998        .retain(|finding| line_in_diff(&finding.output.path, finding.output.line));
999    results
1000        .unused_svelte_events
1001        .retain(|finding| line_in_diff(&finding.event.path, finding.event.line));
1002    results
1003        .unused_server_actions
1004        .retain(|finding| line_in_diff(&finding.action.path, finding.action.line));
1005    results
1006        .unused_load_data_keys
1007        .retain(|finding| line_in_diff(&finding.key.path, finding.key.line));
1008    results
1009        .unresolved_imports
1010        .retain(|finding| line_in_diff(&finding.import.path, finding.import.line));
1011}
1012
1013fn filter_dead_code_security_findings(
1014    results: &mut AnalysisResults,
1015    touches_file: &dyn Fn(&Path) -> bool,
1016    line_in_diff: &dyn Fn(&Path, u32) -> bool,
1017) {
1018    results.security_findings.retain(|finding| {
1019        line_in_diff(&finding.path, finding.line)
1020            || finding.trace.iter().any(|hop| {
1021                line_in_diff(&hop.path, hop.line)
1022                    || (matches!(hop.role, fallow_engine::results::TraceHopRole::SecretSource)
1023                        && touches_file(&hop.path))
1024            })
1025            || finding.reachability.as_ref().is_some_and(|reachability| {
1026                reachability
1027                    .untrusted_source_trace
1028                    .iter()
1029                    .any(|hop| line_in_diff(&hop.path, hop.line))
1030            })
1031    });
1032    results
1033        .security_unresolved_callee_diagnostics
1034        .retain(|finding| line_in_diff(&finding.path, finding.line));
1035}
1036
1037fn filter_dead_code_dependency_findings(
1038    results: &mut AnalysisResults,
1039    line_in_diff: &dyn Fn(&Path, u32) -> bool,
1040) {
1041    for finding in &mut results.unlisted_dependencies {
1042        finding
1043            .dep
1044            .imported_from
1045            .retain(|source| line_in_diff(&source.path, source.line));
1046    }
1047    results
1048        .unlisted_dependencies
1049        .retain(|finding| !finding.dep.imported_from.is_empty());
1050}
1051
1052fn filter_dead_code_graph_findings(
1053    results: &mut AnalysisResults,
1054    touches_file: &dyn Fn(&Path) -> bool,
1055    line_in_diff: &dyn Fn(&Path, u32) -> bool,
1056) {
1057    results.duplicate_exports.retain(|finding| {
1058        finding
1059            .export
1060            .locations
1061            .iter()
1062            .any(|location| line_in_diff(&location.path, location.line))
1063    });
1064    results
1065        .circular_dependencies
1066        .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
1067    results
1068        .re_export_cycles
1069        .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
1070    results
1071        .boundary_violations
1072        .retain(|finding| line_in_diff(&finding.violation.from_path, finding.violation.line));
1073    results
1074        .stale_suppressions
1075        .retain(|finding| line_in_diff(&finding.path, finding.line));
1076}
1077
1078fn filter_dead_code_framework_findings(
1079    results: &mut AnalysisResults,
1080    line_in_diff: &dyn Fn(&Path, u32) -> bool,
1081) {
1082    results
1083        .invalid_client_exports
1084        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
1085    results
1086        .mixed_client_server_barrels
1087        .retain(|finding| line_in_diff(&finding.barrel.path, finding.barrel.line));
1088    results
1089        .misplaced_directives
1090        .retain(|finding| line_in_diff(&finding.directive_site.path, finding.directive_site.line));
1091    results
1092        .route_collisions
1093        .retain(|finding| line_in_diff(&finding.collision.path, finding.collision.line));
1094    results
1095        .dynamic_segment_name_conflicts
1096        .retain(|finding| line_in_diff(&finding.conflict.path, finding.conflict.line));
1097}
1098
1099fn apply_dead_code_file_filter(
1100    options: &DeadCodeOptions,
1101    root: &Path,
1102    results: &mut AnalysisResults,
1103) {
1104    if options.files.is_empty() {
1105        return;
1106    }
1107    let file_set = options
1108        .files
1109        .iter()
1110        .map(|path| {
1111            if is_absolute_path_any_platform(path) {
1112                path.clone()
1113            } else {
1114                root.join(path)
1115            }
1116        })
1117        .collect::<FxHashSet<_>>();
1118    fallow_engine::dead_code::filter_by_changed_files(results, &file_set);
1119    clear_dead_code_dependency_findings(results);
1120}
1121
1122fn apply_dead_code_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1123    if !dead_code_filters_active(filters) {
1124        return;
1125    }
1126    apply_dead_code_core_filters(filters, results);
1127    apply_dead_code_component_filters(filters, results);
1128    apply_dead_code_graph_filters(filters, results);
1129    apply_dead_code_policy_filters(filters, results);
1130    apply_dead_code_catalog_filters(filters, results);
1131}
1132
1133fn dead_code_filters_active(filters: &DeadCodeFilters) -> bool {
1134    filters.unused_files
1135        || filters.unused_exports
1136        || filters.unused_deps
1137        || filters.unused_types
1138        || filters.private_type_leaks
1139        || filters.unused_enum_members
1140        || filters.unused_class_members
1141        || filters.unused_store_members
1142        || filters.unprovided_injects
1143        || filters.unrendered_components
1144        || filters.unused_component_props
1145        || filters.unused_component_emits
1146        || filters.unused_component_inputs
1147        || filters.unused_component_outputs
1148        || filters.unused_svelte_events
1149        || filters.unused_server_actions
1150        || filters.unused_load_data_keys
1151        || filters.unresolved_imports
1152        || filters.unlisted_deps
1153        || filters.duplicate_exports
1154        || filters.circular_deps
1155        || filters.re_export_cycles
1156        || filters.boundary_violations
1157        || filters.policy_violations
1158        || filters.stale_suppressions
1159        || filters.unused_catalog_entries
1160        || filters.empty_catalog_groups
1161        || filters.unresolved_catalog_references
1162        || filters.unused_dependency_overrides
1163        || filters.misconfigured_dependency_overrides
1164}
1165
1166fn apply_dead_code_core_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1167    if !filters.unused_files {
1168        results.unused_files.clear();
1169    }
1170    if !filters.unused_exports {
1171        results.unused_exports.clear();
1172    }
1173    if !filters.unused_types {
1174        results.unused_types.clear();
1175    }
1176    if !filters.private_type_leaks {
1177        results.private_type_leaks.clear();
1178    }
1179    if !filters.unused_deps {
1180        clear_dead_code_dependency_findings(results);
1181    }
1182    if !filters.unused_enum_members {
1183        results.unused_enum_members.clear();
1184    }
1185    if !filters.unused_class_members {
1186        results.unused_class_members.clear();
1187    }
1188    if !filters.unused_store_members {
1189        results.unused_store_members.clear();
1190    }
1191    if !filters.unlisted_deps {
1192        results.unlisted_dependencies.clear();
1193    }
1194}
1195
1196fn clear_dead_code_dependency_findings(results: &mut AnalysisResults) {
1197    results.unused_dependencies.clear();
1198    results.unused_dev_dependencies.clear();
1199    results.unused_optional_dependencies.clear();
1200    results.type_only_dependencies.clear();
1201    results.test_only_dependencies.clear();
1202}
1203
1204fn apply_dead_code_component_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1205    if !filters.unprovided_injects {
1206        results.unprovided_injects.clear();
1207    }
1208    if !filters.unrendered_components {
1209        results.unrendered_components.clear();
1210    }
1211    if !filters.unused_component_props {
1212        results.unused_component_props.clear();
1213    }
1214    if !filters.unused_component_emits {
1215        results.unused_component_emits.clear();
1216    }
1217    if !filters.unused_component_inputs {
1218        results.unused_component_inputs.clear();
1219    }
1220    if !filters.unused_component_outputs {
1221        results.unused_component_outputs.clear();
1222    }
1223    if !filters.unused_svelte_events {
1224        results.unused_svelte_events.clear();
1225    }
1226    if !filters.unused_server_actions {
1227        results.unused_server_actions.clear();
1228    }
1229    if !filters.unused_load_data_keys {
1230        results.unused_load_data_keys.clear();
1231    }
1232    if !filters.unresolved_imports {
1233        results.unresolved_imports.clear();
1234    }
1235}
1236
1237fn apply_dead_code_graph_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1238    if !filters.duplicate_exports {
1239        results.duplicate_exports.clear();
1240    }
1241    if !filters.circular_deps {
1242        results.circular_dependencies.clear();
1243    }
1244    if !filters.re_export_cycles {
1245        results.re_export_cycles.clear();
1246    }
1247    if !filters.boundary_violations {
1248        results.boundary_violations.clear();
1249        results.boundary_coverage_violations.clear();
1250        results.boundary_call_violations.clear();
1251    }
1252}
1253
1254fn apply_dead_code_policy_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1255    if !filters.policy_violations {
1256        results.policy_violations.clear();
1257    }
1258    if !filters.stale_suppressions {
1259        results.stale_suppressions.clear();
1260    }
1261}
1262
1263fn apply_dead_code_catalog_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1264    if !filters.unused_catalog_entries {
1265        results.unused_catalog_entries.clear();
1266    }
1267    if !filters.empty_catalog_groups {
1268        results.empty_catalog_groups.clear();
1269    }
1270    if !filters.unresolved_catalog_references {
1271        results.unresolved_catalog_references.clear();
1272    }
1273    if !filters.unused_dependency_overrides {
1274        results.unused_dependency_overrides.clear();
1275    }
1276    if !filters.misconfigured_dependency_overrides {
1277        results.misconfigured_dependency_overrides.clear();
1278    }
1279}
1280
1281fn detect_duplication_inner(
1282    options: &DuplicationOptions,
1283    resolved: &ProgrammaticAnalysisContext,
1284) -> ProgrammaticResult<DuplicationProgrammaticOutput> {
1285    let start = Instant::now();
1286    let session = load_duplication_session(options, resolved)?;
1287    stash_workspace_diagnostics_for_session(&session);
1288    let dupes_config = build_dupes_config(options, &session.config().duplicates);
1289    let changed_files = changed_files_for_run(resolved)?;
1290    let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
1291    let mut report = if let Some(changed_files) = changed_files.as_ref() {
1292        let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
1293        session
1294            .find_duplicates_touching_files_with_defaults(&dupes_config, &changed_files, cache_dir)
1295            .report
1296    } else {
1297        session
1298            .find_duplicates_with_defaults(&dupes_config, cache_dir)
1299            .report
1300    };
1301
1302    if let Some(diff) = resolved.diff.as_ref() {
1303        filter_by_diff(&mut report, diff, session.root());
1304    }
1305    if let Some(workspace_roots) = resolved.workspace_roots.as_ref() {
1306        filter_by_workspaces(&mut report, workspace_roots, session.root());
1307    }
1308    if let Some(top) = options.top {
1309        apply_top(&mut report, top, session.root());
1310    }
1311
1312    let root = session.root();
1313    let payload = DupesReportPayload::from_report(&report);
1314    let clone_fingerprints = payload
1315        .clone_groups
1316        .iter()
1317        .map(|group| group.fingerprint.as_str())
1318        .collect::<Vec<_>>();
1319    let next_steps = build_dupes_next_steps(DupesNextStepsInput {
1320        suggestions_enabled: suggestions_enabled(),
1321        clone_fingerprints: &clone_fingerprints,
1322        offer_setup: setup_pointer_applicable(root),
1323        impact_digest: None,
1324        audit_changed: fallow_engine::churn::is_git_repo(root),
1325    });
1326    let output: DupesOutput<DupesReportPayload, serde_json::Value> =
1327        build_dupes_output(DupesOutputInput {
1328            schema_version: SCHEMA_VERSION,
1329            version: env!("CARGO_PKG_VERSION").to_string(),
1330            elapsed: start.elapsed(),
1331            report: payload,
1332            grouped_by: None,
1333            total_issues: None,
1334            groups: None,
1335            meta: resolved.explain_enabled().then(dupes_meta),
1336            workspace_diagnostics: fallow_config::workspace_diagnostics_for(root),
1337            next_steps,
1338        });
1339    Ok(DuplicationProgrammaticOutput {
1340        output,
1341        root: session.root().to_path_buf(),
1342        envelope_mode: root_envelope_mode(resolved.legacy_envelope),
1343        telemetry_analysis_run_id: None,
1344    })
1345}
1346
1347fn load_duplication_session(
1348    options: &DuplicationOptions,
1349    resolved: &ProgrammaticAnalysisContext,
1350) -> ProgrammaticResult<AnalysisSession> {
1351    let project_config =
1352        fallow_engine::config_for_project(&resolved.root, resolved.config_path.as_deref())
1353            .map_err(|err| {
1354                ProgrammaticError::new(format!("failed to load config: {err}"), 2)
1355                    .with_code("FALLOW_CONFIG_LOAD_FAILED")
1356                    .with_context("analysis.configPath")
1357            })?;
1358    let project_config = configure_project_for_duplication(project_config, options, resolved);
1359    Ok(AnalysisSession::from_config(project_config))
1360}
1361
1362fn configure_project_for_duplication(
1363    mut project_config: ProjectConfig,
1364    options: &DuplicationOptions,
1365    resolved: &ProgrammaticAnalysisContext,
1366) -> ProjectConfig {
1367    let production = resolved
1368        .production_override
1369        .unwrap_or(project_config.config.production);
1370    project_config.config.production = production;
1371    project_config.config.output = OutputFormat::Json;
1372    project_config.config.threads = resolved.threads;
1373    project_config.config.no_cache = resolved.no_cache;
1374    project_config.config.duplicates =
1375        build_dupes_config(options, &project_config.config.duplicates);
1376    project_config
1377}
1378
1379fn build_dupes_config(options: &DuplicationOptions, config: &DuplicatesConfig) -> DuplicatesConfig {
1380    DuplicatesConfig {
1381        enabled: true,
1382        mode: duplication_mode_to_config(options.mode),
1383        min_tokens: options.min_tokens,
1384        min_lines: options.min_lines,
1385        min_occurrences: options.min_occurrences,
1386        threshold: options.threshold,
1387        ignore: config.ignore.clone(),
1388        ignore_defaults: config.ignore_defaults,
1389        skip_local: options.skip_local || config.skip_local,
1390        cross_language: options.cross_language || config.cross_language,
1391        ignore_imports: options.ignore_imports.unwrap_or(config.ignore_imports),
1392        normalization: config.normalization.clone(),
1393        min_corpus_size_for_shingle_filter: config.min_corpus_size_for_shingle_filter,
1394        min_corpus_size_for_token_cache: config.min_corpus_size_for_token_cache,
1395    }
1396}
1397
1398const fn duplication_mode_to_config(mode: DuplicationMode) -> DetectionMode {
1399    match mode {
1400        DuplicationMode::Strict => DetectionMode::Strict,
1401        DuplicationMode::Mild => DetectionMode::Mild,
1402        DuplicationMode::Weak => DetectionMode::Weak,
1403        DuplicationMode::Semantic => DetectionMode::Semantic,
1404    }
1405}
1406
1407/// Resolve common programmatic analysis options once for a concrete runtime.
1408///
1409/// # Errors
1410///
1411/// Returns a structured programmatic error for invalid roots, configs, thread
1412/// counts, workspace scopes, or explicit diff files.
1413pub fn resolve_programmatic_analysis_context(
1414    options: &AnalysisOptions,
1415) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
1416    validate_analysis_option_shape(options)?;
1417    let root = resolve_analysis_root(options.root.as_deref())?;
1418    validate_analysis_config_path(options.config_path.as_deref())?;
1419    let threads = options.threads.unwrap_or_else(default_threads);
1420    let pool = rayon::ThreadPoolBuilder::new()
1421        .num_threads(threads)
1422        .build()
1423        .map_err(|err| {
1424            ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
1425                .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
1426                .with_context("analysis.threads")
1427        })?;
1428    let diff = options
1429        .diff_file
1430        .as_deref()
1431        .map(|path| load_explicit_diff_file(path, &root))
1432        .transpose()?;
1433    let workspace_roots = resolve_workspace_scope(
1434        &root,
1435        options.workspace.as_deref(),
1436        options.changed_workspaces.as_deref(),
1437    )?;
1438    Ok(ProgrammaticAnalysisContext {
1439        root,
1440        config_path: options.config_path.clone(),
1441        no_cache: options.no_cache,
1442        threads,
1443        pool,
1444        diff,
1445        production_override: options
1446            .production_override
1447            .or_else(|| options.production.then_some(true)),
1448        changed_since: options.changed_since.clone(),
1449        workspace: options.workspace.clone(),
1450        changed_workspaces: options.changed_workspaces.clone(),
1451        workspace_roots,
1452        legacy_envelope: options.legacy_envelope,
1453        explain: options.explain,
1454    })
1455}
1456
1457fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
1458    if options.threads == Some(0) {
1459        return Err(
1460            ProgrammaticError::new("`threads` must be greater than 0", 2)
1461                .with_code("FALLOW_INVALID_THREADS")
1462                .with_context("analysis.threads"),
1463        );
1464    }
1465    if options.workspace.is_some() && options.changed_workspaces.is_some() {
1466        return Err(ProgrammaticError::new(
1467            "`workspace` and `changed_workspaces` are mutually exclusive",
1468            2,
1469        )
1470        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
1471        .with_context("analysis.workspace"));
1472    }
1473    Ok(())
1474}
1475
1476fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
1477    let root = match root {
1478        Some(root) => root.to_path_buf(),
1479        None => std::env::current_dir().map_err(|err| {
1480            ProgrammaticError::new(
1481                format!("failed to resolve current working directory: {err}"),
1482                2,
1483            )
1484            .with_code("FALLOW_CWD_UNAVAILABLE")
1485            .with_context("analysis.root")
1486        })?,
1487    };
1488    if !root.exists() {
1489        return Err(ProgrammaticError::new(
1490            format!("analysis root does not exist: {}", root.display()),
1491            2,
1492        )
1493        .with_code("FALLOW_INVALID_ROOT")
1494        .with_context("analysis.root"));
1495    }
1496    if !root.is_dir() {
1497        return Err(ProgrammaticError::new(
1498            format!("analysis root is not a directory: {}", root.display()),
1499            2,
1500        )
1501        .with_code("FALLOW_INVALID_ROOT")
1502        .with_context("analysis.root"));
1503    }
1504    Ok(root)
1505}
1506
1507fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
1508    if let Some(config_path) = config_path
1509        && !config_path.exists()
1510    {
1511        return Err(ProgrammaticError::new(
1512            format!("config file does not exist: {}", config_path.display()),
1513            2,
1514        )
1515        .with_code("FALLOW_INVALID_CONFIG_PATH")
1516        .with_context("analysis.configPath"));
1517    }
1518    Ok(())
1519}
1520
1521impl ProgrammaticAnalysisContext {
1522    /// Run work inside the per-call Rayon pool.
1523    pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
1524        self.pool.install(f)
1525    }
1526
1527    /// Resolved analysis root.
1528    #[must_use]
1529    pub fn root(&self) -> &Path {
1530        &self.root
1531    }
1532
1533    /// Config path supplied by the caller, if any.
1534    #[must_use]
1535    pub fn config_path(&self) -> &Option<PathBuf> {
1536        &self.config_path
1537    }
1538
1539    /// Whether parser cache use is disabled for this call.
1540    #[must_use]
1541    pub const fn no_cache(&self) -> bool {
1542        self.no_cache
1543    }
1544
1545    /// Effective parser thread count for this call.
1546    #[must_use]
1547    pub const fn threads(&self) -> usize {
1548        self.threads
1549    }
1550
1551    /// Parsed explicit diff file, if supplied.
1552    #[must_use]
1553    pub const fn diff_index(&self) -> Option<&DiffIndex> {
1554        self.diff.as_ref()
1555    }
1556
1557    /// Explicit production override supplied by the caller.
1558    #[must_use]
1559    pub const fn production_override(&self) -> Option<bool> {
1560        self.production_override
1561    }
1562
1563    /// Git ref used to scope changed files.
1564    #[must_use]
1565    pub fn changed_since(&self) -> Option<&str> {
1566        self.changed_since.as_deref()
1567    }
1568
1569    /// Workspace filter patterns supplied by the caller.
1570    #[must_use]
1571    pub fn workspace(&self) -> Option<&[String]> {
1572        self.workspace.as_deref()
1573    }
1574
1575    /// Git ref used to scope changed workspaces.
1576    #[must_use]
1577    pub fn changed_workspaces(&self) -> Option<&str> {
1578        self.changed_workspaces.as_deref()
1579    }
1580
1581    /// Whether API JSON should include explanatory metadata.
1582    #[must_use]
1583    pub const fn explain_enabled(&self) -> bool {
1584        self.explain
1585    }
1586}
1587
1588fn default_threads() -> usize {
1589    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
1590}
1591
1592fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
1593    if path == Path::new("-") {
1594        return Err(ProgrammaticError::new(
1595            "`diff_file` does not support stdin; pass a file path",
1596            2,
1597        )
1598        .with_code("FALLOW_INVALID_DIFF_FILE")
1599        .with_context("analysis.diffFile"));
1600    }
1601    let abs = if is_absolute_path_any_platform(path) {
1602        path.to_path_buf()
1603    } else {
1604        root.join(path)
1605    };
1606    let meta = std::fs::metadata(&abs).map_err(|err| {
1607        ProgrammaticError::new(
1608            format!(
1609                "diff file does not exist or cannot be read: {} ({err})",
1610                abs.display()
1611            ),
1612            2,
1613        )
1614        .with_code("FALLOW_INVALID_DIFF_FILE")
1615        .with_context("analysis.diffFile")
1616    })?;
1617    if !meta.is_file() {
1618        return Err(ProgrammaticError::new(
1619            format!("diff path is not a file: {}", abs.display()),
1620            2,
1621        )
1622        .with_code("FALLOW_INVALID_DIFF_FILE")
1623        .with_context("analysis.diffFile"));
1624    }
1625    if meta.len() > MAX_DIFF_BYTES {
1626        return Err(ProgrammaticError::new(
1627            format!(
1628                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
1629                meta.len(),
1630                abs.display()
1631            ),
1632            2,
1633        )
1634        .with_code("FALLOW_INVALID_DIFF_FILE")
1635        .with_context("analysis.diffFile"));
1636    }
1637    let text = std::fs::read_to_string(&abs).map_err(|err| {
1638        ProgrammaticError::new(
1639            format!("failed to read diff file {}: {err}", abs.display()),
1640            2,
1641        )
1642        .with_code("FALLOW_INVALID_DIFF_FILE")
1643        .with_context("analysis.diffFile")
1644    })?;
1645    Ok(DiffIndex::from_unified_diff(&text))
1646}
1647
1648fn changed_files_for_run(
1649    resolved: &ProgrammaticAnalysisContext,
1650) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
1651    let Some(git_ref) = resolved.changed_since.as_deref() else {
1652        return Ok(None);
1653    };
1654    fallow_engine::changed_files(&resolved.root, git_ref)
1655        .map(Some)
1656        .map_err(|err| {
1657            ProgrammaticError::new(
1658                format!(
1659                    "failed to resolve changed files for ref `{git_ref}`: {}",
1660                    err.describe()
1661                ),
1662                2,
1663            )
1664            .with_code("FALLOW_CHANGED_FILES_FAILED")
1665            .with_context("analysis.changedSince")
1666        })
1667}
1668
1669fn resolve_workspace_scope(
1670    root: &Path,
1671    workspace: Option<&[String]>,
1672    changed_workspaces: Option<&str>,
1673) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
1674    match (workspace, changed_workspaces) {
1675        (Some(patterns), None) => resolve_workspace_filters(root, patterns).map(Some),
1676        (None, Some(git_ref)) => resolve_changed_workspaces(root, git_ref).map(Some),
1677        (None, None) => Ok(None),
1678        (Some(_), Some(_)) => Err(ProgrammaticError::new(
1679            "`workspace` and `changed_workspaces` are mutually exclusive",
1680            2,
1681        )
1682        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
1683        .with_context("analysis.workspace")),
1684    }
1685}
1686
1687fn resolve_workspace_filters(root: &Path, patterns: &[String]) -> ProgrammaticResult<Vec<PathBuf>> {
1688    let workspaces = fallow_config::discover_workspaces(root);
1689    if workspaces.is_empty() {
1690        let joined = patterns
1691            .iter()
1692            .map(|pattern| format!("'{pattern}'"))
1693            .collect::<Vec<_>>()
1694            .join(", ");
1695        return Err(ProgrammaticError::new(
1696            format!(
1697                "`workspace` {joined} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
1698            ),
1699            2,
1700        )
1701        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
1702        .with_context("analysis.workspace"));
1703    }
1704
1705    let rel_paths = workspaces
1706        .iter()
1707        .map(|workspace| relative_workspace_path(&workspace.root, root))
1708        .collect::<Vec<_>>();
1709    let (positive, negative) = split_workspace_patterns(patterns);
1710    let mut matched = match_positive_workspace_patterns(&positive, &workspaces, &rel_paths)?;
1711
1712    for pattern in &negative {
1713        for index in find_workspace_matches(pattern, &workspaces, &rel_paths)? {
1714            matched.remove(&index);
1715        }
1716    }
1717
1718    if matched.is_empty() {
1719        return Err(
1720            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
1721                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
1722                .with_context("analysis.workspace"),
1723        );
1724    }
1725
1726    let mut roots = matched
1727        .into_iter()
1728        .map(|index| workspaces[index].root.clone())
1729        .collect::<Vec<_>>();
1730    roots.sort();
1731    Ok(roots)
1732}
1733
1734fn resolve_changed_workspaces(root: &Path, git_ref: &str) -> ProgrammaticResult<Vec<PathBuf>> {
1735    let workspaces = fallow_config::discover_workspaces(root);
1736    if workspaces.is_empty() {
1737        return Err(ProgrammaticError::new(
1738            format!(
1739                "`changed_workspaces` '{git_ref}' specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
1740            ),
1741            2,
1742        )
1743        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
1744        .with_context("analysis.changedWorkspaces"));
1745    }
1746    let changed_files = fallow_engine::changed_files(root, git_ref).map_err(|err| {
1747        ProgrammaticError::new(
1748            format!(
1749                "failed to resolve changed workspaces for ref `{git_ref}`: {}",
1750                err.describe()
1751            ),
1752            2,
1753        )
1754        .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
1755        .with_context("analysis.changedWorkspaces")
1756    })?;
1757    let mut roots = workspaces
1758        .into_iter()
1759        .filter(|workspace| {
1760            changed_files
1761                .iter()
1762                .any(|file| file.starts_with(&workspace.root))
1763        })
1764        .map(|workspace| workspace.root)
1765        .collect::<Vec<_>>();
1766    roots.sort();
1767    Ok(roots)
1768}
1769
1770fn match_positive_workspace_patterns(
1771    positive: &[&str],
1772    workspaces: &[WorkspaceInfo],
1773    rel_paths: &[String],
1774) -> ProgrammaticResult<FxHashSet<usize>> {
1775    let mut matched = FxHashSet::default();
1776    let mut unmatched = Vec::new();
1777
1778    if positive.is_empty() {
1779        matched.extend(0..workspaces.len());
1780    } else {
1781        for pattern in positive {
1782            let hits = find_workspace_matches(pattern, workspaces, rel_paths)?;
1783            if hits.is_empty() {
1784                unmatched.push((*pattern).to_string());
1785            }
1786            matched.extend(hits);
1787        }
1788    }
1789
1790    if !unmatched.is_empty() {
1791        return Err(ProgrammaticError::new(
1792            format!(
1793                "`workspace` matched no workspace for pattern{}: {}. Available: {}",
1794                if unmatched.len() == 1 { "" } else { "s" },
1795                unmatched
1796                    .iter()
1797                    .map(|pattern| format!("'{pattern}'"))
1798                    .collect::<Vec<_>>()
1799                    .join(", "),
1800                format_available_workspaces(workspaces),
1801            ),
1802            2,
1803        )
1804        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
1805        .with_context("analysis.workspace"));
1806    }
1807
1808    Ok(matched)
1809}
1810
1811fn find_workspace_matches(
1812    pattern: &str,
1813    workspaces: &[WorkspaceInfo],
1814    rel_paths: &[String],
1815) -> ProgrammaticResult<Vec<usize>> {
1816    if let Some(index) = workspaces
1817        .iter()
1818        .position(|workspace| workspace.name == pattern)
1819    {
1820        return Ok(vec![index]);
1821    }
1822    if let Some(index) = rel_paths.iter().position(|path| path == pattern) {
1823        return Ok(vec![index]);
1824    }
1825
1826    let glob = Glob::new(pattern).map_err(|err| {
1827        ProgrammaticError::new(format!("invalid `workspace` pattern '{pattern}': {err}"), 2)
1828            .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
1829            .with_context("analysis.workspace")
1830    })?;
1831    let matcher = glob.compile_matcher();
1832    let hits = workspaces
1833        .iter()
1834        .enumerate()
1835        .filter_map(|(index, workspace)| {
1836            (matcher.is_match(&workspace.name) || matcher.is_match(&rel_paths[index]))
1837                .then_some(index)
1838        })
1839        .collect();
1840    Ok(hits)
1841}
1842
1843fn split_workspace_patterns(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
1844    let mut positive = Vec::new();
1845    let mut negative = Vec::new();
1846    for pattern in patterns {
1847        let trimmed = pattern.trim();
1848        if trimmed.is_empty() {
1849            continue;
1850        }
1851        if let Some(negative_pattern) = trimmed.strip_prefix('!') {
1852            let negative_pattern = negative_pattern.trim();
1853            if !negative_pattern.is_empty() {
1854                negative.push(negative_pattern);
1855            }
1856        } else {
1857            positive.push(trimmed);
1858        }
1859    }
1860    (positive, negative)
1861}
1862
1863fn format_available_workspaces(workspaces: &[WorkspaceInfo]) -> String {
1864    const MAX_SHOWN: usize = 10;
1865    let total = workspaces.len();
1866    if total <= MAX_SHOWN {
1867        return workspaces
1868            .iter()
1869            .map(|workspace| workspace.name.as_str())
1870            .collect::<Vec<_>>()
1871            .join(", ");
1872    }
1873    let shown = workspaces
1874        .iter()
1875        .take(MAX_SHOWN)
1876        .map(|workspace| workspace.name.as_str())
1877        .collect::<Vec<_>>()
1878        .join(", ");
1879    format!(
1880        "{shown}, ... and {} more ({total} total)",
1881        total - MAX_SHOWN
1882    )
1883}
1884
1885fn relative_workspace_path(workspace_root: &Path, root: &Path) -> String {
1886    workspace_root
1887        .strip_prefix(root)
1888        .unwrap_or(workspace_root)
1889        .to_string_lossy()
1890        .replace('\\', "/")
1891}
1892
1893fn filter_by_diff(report: &mut DuplicationReport, diff_index: &DiffIndex, root: &Path) {
1894    let instance_overlaps = |instance: &CloneInstance| -> bool {
1895        let Some(rel) = relative_to_diff_path(&instance.file, root) else {
1896            return true;
1897        };
1898        let start = u64::try_from(instance.start_line).unwrap_or(u64::MAX);
1899        let end = u64::try_from(instance.end_line).unwrap_or(u64::MAX);
1900        diff_index.range_overlaps_added(&rel, start, end)
1901    };
1902    report
1903        .clone_groups
1904        .retain(|g| g.instances.iter().any(instance_overlaps));
1905    rebuild_duplication_derived_fields(report, root);
1906}
1907
1908fn filter_by_workspaces(report: &mut DuplicationReport, workspace_roots: &[PathBuf], root: &Path) {
1909    report.clone_groups.retain(|group| {
1910        group.instances.iter().any(|instance| {
1911            workspace_roots
1912                .iter()
1913                .any(|workspace_root| instance.file.starts_with(workspace_root))
1914        })
1915    });
1916    rebuild_duplication_derived_fields(report, root);
1917}
1918
1919fn apply_top(report: &mut DuplicationReport, n: usize, root: &Path) {
1920    report.clone_groups.sort_by(|a, b| {
1921        b.instances
1922            .len()
1923            .cmp(&a.instances.len())
1924            .then(b.line_count.cmp(&a.line_count))
1925            .then_with(|| match (a.instances.first(), b.instances.first()) {
1926                (Some(ai), Some(bi)) => ai
1927                    .file
1928                    .cmp(&bi.file)
1929                    .then(ai.start_line.cmp(&bi.start_line)),
1930                (Some(_), None) => std::cmp::Ordering::Less,
1931                (None, Some(_)) => std::cmp::Ordering::Greater,
1932                (None, None) => std::cmp::Ordering::Equal,
1933            })
1934    });
1935    report.clone_groups.truncate(n);
1936    rebuild_duplication_derived_fields(report, root);
1937    report.sort();
1938}
1939
1940fn rebuild_duplication_derived_fields(report: &mut DuplicationReport, root: &Path) {
1941    report.clone_families =
1942        fallow_engine::duplicates::families::group_into_families(&report.clone_groups, root);
1943    report.mirrored_directories = fallow_engine::duplicates::families::detect_mirrored_directories(
1944        &report.clone_families,
1945        root,
1946    );
1947    report.stats = recompute_stats(report);
1948}
1949
1950fn recompute_stats(report: &DuplicationReport) -> DuplicationStats {
1951    let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
1952    let mut line_ranges: FxHashMap<&Path, Vec<(usize, usize)>> = FxHashMap::default();
1953    let mut clone_instances = 0_usize;
1954    let mut duplicated_tokens = 0_usize;
1955    for group in &report.clone_groups {
1956        duplicated_tokens += group.token_count * group.instances.len();
1957        for instance in &group.instances {
1958            files_with_clones.insert(&instance.file);
1959            clone_instances += 1;
1960            line_ranges
1961                .entry(&instance.file)
1962                .or_default()
1963                .push((instance.start_line, instance.end_line));
1964        }
1965    }
1966    let duplicated_lines = line_ranges
1967        .into_values()
1968        .map(count_merged_lines)
1969        .sum::<usize>();
1970    let duplication_percentage = if report.stats.total_lines == 0 {
1971        0.0
1972    } else {
1973        (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
1974    };
1975    DuplicationStats {
1976        total_files: report.stats.total_files,
1977        files_with_clones: files_with_clones.len(),
1978        total_lines: report.stats.total_lines,
1979        duplicated_lines,
1980        total_tokens: report.stats.total_tokens,
1981        duplicated_tokens,
1982        clone_groups: report.clone_groups.len(),
1983        clone_instances,
1984        duplication_percentage,
1985        clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
1986    }
1987}
1988
1989fn count_merged_lines(mut ranges: Vec<(usize, usize)>) -> usize {
1990    if ranges.is_empty() {
1991        return 0;
1992    }
1993    ranges.sort_unstable();
1994    let mut total = 0_usize;
1995    let mut current = ranges[0];
1996    for (start, end) in ranges.into_iter().skip(1) {
1997        if start <= current.1.saturating_add(1) {
1998            current.1 = current.1.max(end);
1999        } else {
2000            total += current.1.saturating_sub(current.0).saturating_add(1);
2001            current = (start, end);
2002        }
2003    }
2004    total + current.1.saturating_sub(current.0).saturating_add(1)
2005}
2006
2007const fn root_envelope_mode(legacy_envelope: bool) -> RootEnvelopeMode {
2008    RootEnvelopeMode::from_legacy(legacy_envelope)
2009}
2010
2011#[cfg(test)]
2012mod tests {
2013    use std::path::PathBuf;
2014
2015    use super::*;
2016
2017    struct FakeHealthRunner {
2018        root: PathBuf,
2019        telemetry_analysis_run_id: Option<String>,
2020    }
2021
2022    impl ProgrammaticHealthRunner for FakeHealthRunner {
2023        fn run_programmatic_health(
2024            &self,
2025            _options: &ComplexityOptions,
2026        ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
2027            let project_config = fallow_engine::config_for_project_analysis(
2028                &self.root,
2029                None,
2030                ProjectConfigOptions {
2031                    output: OutputFormat::Json,
2032                    no_cache: true,
2033                    threads: 1,
2034                    production_override: None,
2035                    quiet: true,
2036                    analysis: ProductionAnalysis::Health,
2037                },
2038            )
2039            .expect("test config loads");
2040
2041            Ok(ProgrammaticHealthRun {
2042                analysis: fallow_engine::HealthAnalysisResult {
2043                    report: HealthReport::default(),
2044                    grouping: None,
2045                    group_resolver: None,
2046                    config: project_config.config,
2047                    elapsed: std::time::Duration::ZERO,
2048                    timings: None,
2049                    coverage_gaps_has_findings: false,
2050                    should_fail_on_coverage_gaps: false,
2051                },
2052                workspace_diagnostics: vec![WorkspaceDiagnostic::new(
2053                    &self.root,
2054                    self.root.join("package.json"),
2055                    fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
2056                )],
2057                next_step_facts: ProgrammaticHealthNextStepFacts {
2058                    suggestions_enabled: true,
2059                    offer_setup: false,
2060                    impact_digest: Some(fallow_output::ImpactDigestCounts {
2061                        containment_count: 1,
2062                        resolved_total: 0,
2063                    }),
2064                    audit_changed: false,
2065                },
2066                telemetry_analysis_run_id: self.telemetry_analysis_run_id.clone(),
2067            })
2068        }
2069    }
2070
2071    fn analysis_at(root: &Path) -> AnalysisOptions {
2072        AnalysisOptions {
2073            root: Some(root.to_path_buf()),
2074            ..AnalysisOptions::default()
2075        }
2076    }
2077
2078    #[test]
2079    fn derives_programmatic_health_execution_options_from_api_contracts() {
2080        let project = tempfile::tempdir().expect("temp dir");
2081        let root = project.path();
2082        let options = ComplexityOptions {
2083            analysis: AnalysisOptions {
2084                root: Some(root.to_path_buf()),
2085                no_cache: true,
2086                threads: Some(2),
2087                production_override: Some(true),
2088                explain: true,
2089                ..AnalysisOptions::default()
2090            },
2091            max_cyclomatic: Some(12),
2092            top: Some(5),
2093            complexity: true,
2094            ownership: true,
2095            score: true,
2096            min_commits: Some(3),
2097            ..ComplexityOptions::default()
2098        };
2099        let resolved = resolve_programmatic_analysis_context(&options.analysis)
2100            .expect("programmatic context resolves");
2101
2102        let execution = derive_programmatic_health_execution_options(&resolved, &options);
2103
2104        assert_eq!(execution.root, root);
2105        assert!(matches!(execution.output, OutputFormat::Human));
2106        assert!(execution.no_cache);
2107        assert_eq!(execution.threads, 2);
2108        assert!(execution.quiet);
2109        assert!(!execution.complexity_breakdown);
2110        assert_eq!(execution.thresholds.max_cyclomatic, Some(12));
2111        assert_eq!(execution.top, Some(5));
2112        assert!(execution.production);
2113        assert_eq!(execution.production_override, Some(true));
2114        assert!(execution.complexity);
2115        assert!(execution.hotspots);
2116        assert!(execution.ownership);
2117        assert!(execution.score);
2118        assert_eq!(execution.min_commits, Some(3));
2119        assert!(execution.explain);
2120        assert!(execution.enforce_coverage_gap_gate);
2121        assert!(!execution.performance);
2122        assert!(execution.runtime_coverage.is_none());
2123        assert!(execution.group_by.is_none());
2124    }
2125
2126    #[test]
2127    fn serialize_health_report_json_tags_meta_and_strips_paths() {
2128        let root = Path::new("/repo");
2129        let json = serialize_health_report_json(HealthJsonReportInput {
2130            report: HealthReport::default(),
2131            root,
2132            elapsed: std::time::Duration::ZERO,
2133            explain: true,
2134            grouped_by: None,
2135            groups: None,
2136            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
2137                Path::new("/repo"),
2138                PathBuf::from("/repo/package.json"),
2139                fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
2140            )],
2141            next_steps: vec![NextStep {
2142                id: "inspect-health".to_string(),
2143                command: "fallow health --format json".to_string(),
2144                reason: "inspect health details".to_string(),
2145            }],
2146            envelope_mode: RootEnvelopeMode::Tagged,
2147            telemetry_analysis_run_id: Some("run-api-health"),
2148        })
2149        .expect("health JSON serializes");
2150
2151        assert_eq!(json["kind"], "health");
2152        assert_eq!(json["schema_version"], HEALTH_SCHEMA_VERSION);
2153        assert!(json["_meta"].is_object());
2154        assert_eq!(
2155            json["_meta"]["telemetry"]["analysis_run_id"],
2156            "run-api-health"
2157        );
2158        assert_eq!(json["workspace_diagnostics"][0]["path"], "package.json");
2159        assert_eq!(json["next_steps"][0]["id"], "inspect-health");
2160    }
2161
2162    #[test]
2163    fn serialize_health_report_json_respects_legacy_envelope() {
2164        let json = serialize_health_report_json(HealthJsonReportInput {
2165            report: HealthReport::default(),
2166            root: Path::new("/repo"),
2167            elapsed: std::time::Duration::ZERO,
2168            explain: false,
2169            grouped_by: None,
2170            groups: None,
2171            workspace_diagnostics: Vec::new(),
2172            next_steps: Vec::new(),
2173            envelope_mode: RootEnvelopeMode::Legacy,
2174            telemetry_analysis_run_id: None,
2175        })
2176        .expect("health JSON serializes");
2177
2178        assert!(json.get("kind").is_none());
2179    }
2180
2181    #[test]
2182    fn programmatic_health_runner_serializes_api_owned_output() {
2183        let project = tempfile::tempdir().expect("temp dir");
2184        let root = project.path().to_path_buf();
2185        let json = compute_health_with_runner(
2186            &ComplexityOptions {
2187                analysis: AnalysisOptions {
2188                    explain: true,
2189                    ..AnalysisOptions::default()
2190                },
2191                ..ComplexityOptions::default()
2192            },
2193            &FakeHealthRunner {
2194                root,
2195                telemetry_analysis_run_id: Some("run-123".to_string()),
2196            },
2197        )
2198        .expect("programmatic health should serialize");
2199
2200        assert_eq!(json["kind"], "health");
2201        assert_eq!(json["workspace_diagnostics"][0]["path"], "package.json");
2202        assert_eq!(json["next_steps"][0]["id"], "impact-report");
2203        assert_eq!(
2204            json["_meta"]["telemetry"]["analysis_run_id"],
2205            serde_json::Value::from("run-123")
2206        );
2207    }
2208
2209    #[test]
2210    fn detect_duplication_returns_dupes_envelope() {
2211        let project = tempfile::tempdir().expect("temp dir");
2212        let root = project.path();
2213        std::fs::create_dir(root.join("src")).expect("src dir");
2214        let code = "export function repeated() {\n  return ['a', 'b', 'c'].join(',');\n}\n";
2215        std::fs::write(root.join("src/a.ts"), code).expect("file");
2216        std::fs::write(root.join("src/b.ts"), code).expect("file");
2217
2218        let json = detect_duplication(&DuplicationOptions {
2219            analysis: analysis_at(root),
2220            min_tokens: 1,
2221            min_lines: 1,
2222            ..DuplicationOptions::default()
2223        })
2224        .expect("duplication succeeds");
2225
2226        assert_eq!(json["kind"], "dupes");
2227        assert!(json["clone_groups"].is_array());
2228        assert!(json["stats"].is_object());
2229    }
2230
2231    /// A monorepo whose `workspaces` glob matches a directory with no
2232    /// `package.json` produces a `GlobMatchedNoPackageJson` workspace
2233    /// diagnostic that the CLI surfaces on `workspace_diagnostics[]`, plus
2234    /// unused exports + a clone that drive `next_steps[]`. The api / napi
2235    /// surface must carry the same enrichment the CLI emits.
2236    fn enriched_project() -> tempfile::TempDir {
2237        let project = tempfile::tempdir().expect("temp dir");
2238        let root = project.path();
2239        // `packages/empty` matches the glob but has no package.json -> diagnostic.
2240        std::fs::create_dir_all(root.join("packages/empty")).expect("empty pkg dir");
2241        std::fs::write(
2242            root.join("packages/empty/note.txt"),
2243            "no package.json here\n",
2244        )
2245        .expect("note");
2246        write_json(
2247            root.join("package.json"),
2248            r#"{"name":"api-enriched","main":"src/index.ts","workspaces":["packages/*"]}"#,
2249        );
2250        std::fs::create_dir(root.join("src")).expect("src dir");
2251        std::fs::write(
2252            root.join("src/index.ts"),
2253            "import './a';\nimport './b';\nexport const entry = 1;\nconsole.log(entry);\n",
2254        )
2255        .expect("entry");
2256        // Identical bodies so dupes detection (and the trace-clone next step)
2257        // has a clone to report, plus an unused export per file.
2258        let clone = "export function repeated() {\n  return ['x', 'y', 'z'].join(',');\n}\n";
2259        std::fs::write(root.join("src/a.ts"), clone).expect("a");
2260        std::fs::write(root.join("src/b.ts"), clone).expect("b");
2261        project
2262    }
2263
2264    fn has_glob_no_package_json(diagnostics: &serde_json::Value) -> bool {
2265        diagnostics
2266            .as_array()
2267            .into_iter()
2268            .flatten()
2269            .any(|diag| diag["kind"] == "glob-matched-no-package-json")
2270    }
2271
2272    /// Regression guard: the napi/api dead-code path must populate
2273    /// `workspace_diagnostics` and `next_steps` exactly like the CLI's
2274    /// `serialize_check_json` route does. The pre-fix code hardcoded both to
2275    /// empty, silently dropping the enrichment for `fallow/types` embedders.
2276    #[test]
2277    fn detect_dead_code_carries_workspace_diagnostics_and_next_steps() {
2278        let project = enriched_project();
2279        let root = project.path();
2280
2281        let json = detect_dead_code(&DeadCodeOptions {
2282            analysis: analysis_at(root),
2283            filters: DeadCodeFilters {
2284                unused_exports: true,
2285                ..DeadCodeFilters::default()
2286            },
2287            ..DeadCodeOptions::default()
2288        })
2289        .expect("dead-code succeeds");
2290
2291        // Findings exist, so the enrichment must be present (not the dropped
2292        // empties the crate-split regression produced).
2293        assert!(
2294            !json["unused_exports"].as_array().expect("array").is_empty(),
2295            "fixture must produce unused exports to drive next_steps"
2296        );
2297        assert!(
2298            has_glob_no_package_json(&json["workspace_diagnostics"]),
2299            "workspace_diagnostics must carry the glob-no-package-json diagnostic, got {:?}",
2300            json["workspace_diagnostics"]
2301        );
2302        assert!(
2303            json["next_steps"]
2304                .as_array()
2305                .is_some_and(|steps| !steps.is_empty()),
2306            "next_steps must be populated for a run with findings, got {:?}",
2307            json["next_steps"]
2308        );
2309    }
2310
2311    /// Companion regression guard for the duplication path: the napi/api dupes
2312    /// JSON must carry `workspace_diagnostics`, `next_steps`, and (under
2313    /// `explain`) the `_meta` block, matching the CLI's `build_duplication_json`
2314    /// route. The pre-fix code hardcoded `meta: None` and both vecs empty.
2315    #[test]
2316    fn detect_duplication_carries_meta_diagnostics_and_next_steps() {
2317        let project = enriched_project();
2318        let root = project.path();
2319
2320        let json = detect_duplication(&DuplicationOptions {
2321            analysis: AnalysisOptions {
2322                explain: true,
2323                ..analysis_at(root)
2324            },
2325            min_tokens: 1,
2326            min_lines: 1,
2327            ..DuplicationOptions::default()
2328        })
2329        .expect("duplication succeeds");
2330
2331        assert!(
2332            !json["clone_groups"].as_array().expect("array").is_empty(),
2333            "fixture must produce a clone to drive trace-clone next step"
2334        );
2335        assert!(
2336            json["_meta"].is_object(),
2337            "explain mode must emit the dupes _meta block, got {:?}",
2338            json["_meta"]
2339        );
2340        assert!(
2341            has_glob_no_package_json(&json["workspace_diagnostics"]),
2342            "workspace_diagnostics must carry the glob-no-package-json diagnostic, got {:?}",
2343            json["workspace_diagnostics"]
2344        );
2345        assert!(
2346            json["next_steps"]
2347                .as_array()
2348                .is_some_and(|steps| !steps.is_empty()),
2349            "next_steps must be populated for a run with clones, got {:?}",
2350            json["next_steps"]
2351        );
2352    }
2353
2354    #[test]
2355    fn run_duplication_returns_typed_output_before_json() {
2356        let project = tempfile::tempdir().expect("temp dir");
2357        let root = project.path();
2358        std::fs::create_dir(root.join("src")).expect("src dir");
2359        std::fs::write(root.join("src/a.ts"), "export const a = 1;\n").expect("file");
2360
2361        let run = run_duplication(&DuplicationOptions {
2362            analysis: analysis_at(root),
2363            ..DuplicationOptions::default()
2364        })
2365        .expect("duplication succeeds");
2366
2367        assert_eq!(run.output.schema_version.0, SCHEMA_VERSION);
2368        assert_eq!(run.root, root);
2369        assert_eq!(run.envelope_mode, RootEnvelopeMode::Tagged);
2370
2371        let json = run
2372            .into_json()
2373            .expect("typed duplication output serializes");
2374        assert_eq!(json["kind"], "dupes");
2375    }
2376
2377    #[test]
2378    fn detect_duplication_legacy_envelope_removes_root_kind() {
2379        let project = tempfile::tempdir().expect("temp dir");
2380        let root = project.path();
2381        std::fs::create_dir(root.join("src")).expect("src dir");
2382        std::fs::write(root.join("src/a.ts"), "export const a = 1;\n").expect("file");
2383
2384        let json = detect_duplication(&DuplicationOptions {
2385            analysis: AnalysisOptions {
2386                legacy_envelope: true,
2387                ..analysis_at(root)
2388            },
2389            ..DuplicationOptions::default()
2390        })
2391        .expect("duplication succeeds");
2392
2393        assert!(json.get("kind").is_none());
2394    }
2395
2396    #[test]
2397    fn detect_dead_code_returns_dead_code_envelope() {
2398        let project = dead_code_project();
2399        let root = project.path();
2400
2401        let json = detect_dead_code(&DeadCodeOptions {
2402            analysis: analysis_at(root),
2403            filters: DeadCodeFilters {
2404                unused_exports: true,
2405                ..DeadCodeFilters::default()
2406            },
2407            ..DeadCodeOptions::default()
2408        })
2409        .expect("dead-code succeeds");
2410
2411        assert_eq!(json["kind"], "dead-code");
2412        assert_eq!(json["schema_version"], CHECK_SCHEMA_VERSION);
2413        assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
2414    }
2415
2416    #[test]
2417    fn run_dead_code_returns_typed_output_before_json() {
2418        let project = dead_code_project();
2419        let root = project.path();
2420
2421        let run = run_dead_code(&DeadCodeOptions {
2422            analysis: analysis_at(root),
2423            filters: DeadCodeFilters {
2424                unused_exports: true,
2425                ..DeadCodeFilters::default()
2426            },
2427            ..DeadCodeOptions::default()
2428        })
2429        .expect("dead-code succeeds");
2430
2431        assert_eq!(run.output.schema_version.0, CHECK_SCHEMA_VERSION);
2432        assert_eq!(run.output.results.unused_exports.len(), 2);
2433        assert_eq!(run.root, root);
2434        assert_eq!(run.envelope_mode, RootEnvelopeMode::Tagged);
2435
2436        let json = run.into_json().expect("typed dead-code output serializes");
2437        assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
2438    }
2439
2440    #[test]
2441    fn run_dead_code_family_helpers_return_typed_filtered_output() {
2442        let project = dead_code_project();
2443        let root = project.path();
2444        let options = DeadCodeOptions {
2445            analysis: analysis_at(root),
2446            ..DeadCodeOptions::default()
2447        };
2448
2449        let circular = run_circular_dependencies(&options).expect("circular helper");
2450        let boundary = run_boundary_violations(&options).expect("boundary helper");
2451
2452        assert!(circular.output.results.unused_exports.is_empty());
2453        assert!(boundary.output.results.unused_exports.is_empty());
2454        assert_eq!(circular.output.total_issues, 0);
2455        assert_eq!(boundary.output.total_issues, 0);
2456    }
2457
2458    #[test]
2459    fn detect_dead_code_legacy_envelope_removes_root_kind() {
2460        let project = dead_code_project();
2461        let root = project.path();
2462
2463        let json = detect_dead_code(&DeadCodeOptions {
2464            analysis: AnalysisOptions {
2465                legacy_envelope: true,
2466                ..analysis_at(root)
2467            },
2468            filters: DeadCodeFilters {
2469                unused_exports: true,
2470                ..DeadCodeFilters::default()
2471            },
2472            ..DeadCodeOptions::default()
2473        })
2474        .expect("dead-code succeeds");
2475
2476        assert!(json.get("kind").is_none());
2477    }
2478
2479    #[test]
2480    fn detect_dead_code_explain_includes_output_owned_meta() {
2481        let project = dead_code_project();
2482        let root = project.path();
2483
2484        let json = detect_dead_code(&DeadCodeOptions {
2485            analysis: AnalysisOptions {
2486                explain: true,
2487                ..analysis_at(root)
2488            },
2489            filters: DeadCodeFilters {
2490                unused_exports: true,
2491                ..DeadCodeFilters::default()
2492            },
2493            ..DeadCodeOptions::default()
2494        })
2495        .expect("dead-code succeeds");
2496
2497        assert_eq!(json["kind"], "dead-code");
2498        assert_eq!(
2499            json["_meta"]["docs"].as_str(),
2500            Some(fallow_output::CHECK_DOCS)
2501        );
2502        assert!(json["_meta"]["rules"]["unused-export"].is_object());
2503    }
2504
2505    #[test]
2506    fn detect_dead_code_marks_duplicate_export_config_action_fixable() {
2507        let project = duplicate_export_project();
2508        let root = project.path();
2509
2510        let json = detect_dead_code(&DeadCodeOptions {
2511            analysis: analysis_at(root),
2512            filters: DeadCodeFilters {
2513                duplicate_exports: true,
2514                ..DeadCodeFilters::default()
2515            },
2516            ..DeadCodeOptions::default()
2517        })
2518        .expect("dead-code succeeds");
2519
2520        let action = &json["duplicate_exports"][0]["actions"][0];
2521        assert_eq!(action["type"], "add-to-config");
2522        assert_eq!(action["auto_fixable"], true);
2523    }
2524
2525    #[test]
2526    fn detect_dead_code_keeps_duplicate_export_config_action_blocked_in_subpackage() {
2527        let workspace = tempfile::tempdir().expect("temp dir");
2528        std::fs::write(
2529            workspace.path().join("pnpm-workspace.yaml"),
2530            "packages:\n  - packages/*\n",
2531        )
2532        .expect("workspace");
2533        let root = workspace.path().join("packages/app");
2534        duplicate_export_project_at(&root);
2535
2536        let json = detect_dead_code(&DeadCodeOptions {
2537            analysis: analysis_at(&root),
2538            filters: DeadCodeFilters {
2539                duplicate_exports: true,
2540                ..DeadCodeFilters::default()
2541            },
2542            ..DeadCodeOptions::default()
2543        })
2544        .expect("dead-code succeeds");
2545
2546        let action = &json["duplicate_exports"][0]["actions"][0];
2547        assert_eq!(action["type"], "add-to-config");
2548        assert_eq!(action["auto_fixable"], false);
2549    }
2550
2551    #[test]
2552    fn detect_dead_code_file_filter_scopes_source_findings() {
2553        let project = dead_code_project();
2554        let root = project.path();
2555
2556        let json = detect_dead_code(&DeadCodeOptions {
2557            analysis: analysis_at(root),
2558            filters: DeadCodeFilters {
2559                unused_exports: true,
2560                ..DeadCodeFilters::default()
2561            },
2562            files: vec![PathBuf::from("src/a.ts")],
2563            ..DeadCodeOptions::default()
2564        })
2565        .expect("dead-code succeeds");
2566
2567        assert_eq!(unused_export_names(&json), vec!["deadA"]);
2568    }
2569
2570    #[test]
2571    fn detect_dead_code_diff_file_filters_source_findings() {
2572        let project = dead_code_project();
2573        let root = project.path();
2574        std::fs::write(
2575            root.join("a.diff"),
2576            "diff --git a/src/a.ts b/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n+export const deadA = 1;\n",
2577        )
2578        .expect("diff");
2579
2580        let json = detect_dead_code(&DeadCodeOptions {
2581            analysis: AnalysisOptions {
2582                diff_file: Some(PathBuf::from("a.diff")),
2583                ..analysis_at(root)
2584            },
2585            filters: DeadCodeFilters {
2586                unused_exports: true,
2587                ..DeadCodeFilters::default()
2588            },
2589            ..DeadCodeOptions::default()
2590        })
2591        .expect("dead-code succeeds");
2592
2593        assert_eq!(unused_export_names(&json), vec!["deadA"]);
2594    }
2595
2596    #[test]
2597    fn detect_circular_dependencies_keeps_dead_code_envelope_but_filters_other_findings() {
2598        let project = dead_code_project();
2599        let root = project.path();
2600
2601        let json = detect_circular_dependencies(&DeadCodeOptions {
2602            analysis: analysis_at(root),
2603            ..DeadCodeOptions::default()
2604        })
2605        .expect("circular helper succeeds");
2606
2607        assert_eq!(json["kind"], "dead-code");
2608        assert_eq!(json["total_issues"], 0);
2609        assert!(json["circular_dependencies"].as_array().is_some());
2610        assert!(json["unused_exports"].as_array().is_none_or(Vec::is_empty));
2611    }
2612
2613    #[test]
2614    fn detect_boundary_violations_keeps_only_boundary_family() {
2615        let project = dead_code_project();
2616        let root = project.path();
2617
2618        let json = detect_boundary_violations(&DeadCodeOptions {
2619            analysis: analysis_at(root),
2620            ..DeadCodeOptions::default()
2621        })
2622        .expect("boundary helper succeeds");
2623
2624        assert_eq!(json["kind"], "dead-code");
2625        assert_eq!(json["total_issues"], 0);
2626        assert!(json["boundary_violations"].as_array().is_some());
2627        assert!(json["unused_exports"].as_array().is_none_or(Vec::is_empty));
2628    }
2629
2630    #[test]
2631    fn diff_file_filters_clone_groups() {
2632        let root = PathBuf::from("/repo");
2633        let mut report = DuplicationReport {
2634            clone_groups: vec![
2635                group(vec![
2636                    instance("/repo/src/a.ts", 1, 3),
2637                    instance("/repo/src/b.ts", 1, 3),
2638                ]),
2639                group(vec![
2640                    instance("/repo/src/c.ts", 10, 12),
2641                    instance("/repo/src/d.ts", 1, 3),
2642                ]),
2643            ],
2644            stats: DuplicationStats {
2645                total_files: 4,
2646                total_lines: 100,
2647                total_tokens: 100,
2648                clone_groups: 2,
2649                clone_instances: 4,
2650                ..DuplicationStats::default()
2651            },
2652            ..DuplicationReport::default()
2653        };
2654        let diff = DiffIndex::from_unified_diff(
2655            "diff --git a/src/a.ts b/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,3 @@\n+added\n context\n",
2656        );
2657
2658        filter_by_diff(&mut report, &diff, &root);
2659
2660        assert_eq!(report.clone_groups.len(), 1);
2661        assert_eq!(
2662            report.clone_groups[0].instances[0].file,
2663            root.join("src/a.ts")
2664        );
2665    }
2666
2667    #[test]
2668    fn workspace_scope_filters_clone_groups() {
2669        let root = PathBuf::from("/repo");
2670        let mut report = DuplicationReport {
2671            clone_groups: vec![
2672                group(vec![
2673                    instance("/repo/packages/app/a.ts", 1, 3),
2674                    instance("/repo/packages/shared/b.ts", 1, 3),
2675                ]),
2676                group(vec![
2677                    instance("/repo/packages/docs/c.ts", 1, 3),
2678                    instance("/repo/packages/docs/d.ts", 1, 3),
2679                ]),
2680            ],
2681            stats: DuplicationStats {
2682                total_files: 4,
2683                total_lines: 100,
2684                total_tokens: 100,
2685                clone_groups: 2,
2686                clone_instances: 4,
2687                ..DuplicationStats::default()
2688            },
2689            ..DuplicationReport::default()
2690        };
2691
2692        filter_by_workspaces(&mut report, &[root.join("packages/app")], &root);
2693
2694        assert_eq!(report.clone_groups.len(), 1);
2695        assert_eq!(
2696            report.clone_groups[0].instances[0].file,
2697            root.join("packages/app/a.ts")
2698        );
2699    }
2700
2701    #[test]
2702    fn workspace_patterns_match_names_paths_and_negation() {
2703        let project = tempfile::tempdir().expect("temp dir");
2704        let root = project.path();
2705        write_json(
2706            root.join("package.json"),
2707            r#"{"workspaces":["packages/*"]}"#,
2708        );
2709        write_workspace(root, "packages/app", "@scope/app");
2710        write_workspace(root, "packages/docs", "docs");
2711
2712        let roots =
2713            resolve_workspace_filters(root, &["packages/*".to_string(), "!docs".to_string()])
2714                .expect("workspace filters resolve");
2715
2716        assert_eq!(roots, vec![root.join("packages/app")]);
2717    }
2718
2719    fn instance(path: &str, start_line: usize, end_line: usize) -> CloneInstance {
2720        CloneInstance {
2721            file: PathBuf::from(path),
2722            start_line,
2723            end_line,
2724            start_col: 0,
2725            end_col: 0,
2726            fragment: String::new(),
2727        }
2728    }
2729
2730    fn group(instances: Vec<CloneInstance>) -> fallow_engine::duplicates::CloneGroup {
2731        fallow_engine::duplicates::CloneGroup {
2732            instances,
2733            token_count: 10,
2734            line_count: 3,
2735        }
2736    }
2737
2738    fn dead_code_project() -> tempfile::TempDir {
2739        let project = tempfile::tempdir().expect("temp dir");
2740        let root = project.path();
2741        std::fs::create_dir(root.join("src")).expect("src dir");
2742        write_json(
2743            root.join("package.json"),
2744            r#"{"name":"api-dead-code","main":"src/index.ts"}"#,
2745        );
2746        std::fs::write(
2747            root.join("src/index.ts"),
2748            "import './a';\nimport './b';\nexport const entry = 1;\nconsole.log(entry);\n",
2749        )
2750        .expect("entry");
2751        std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").expect("a");
2752        std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").expect("b");
2753        project
2754    }
2755
2756    fn duplicate_export_project() -> tempfile::TempDir {
2757        let project = tempfile::tempdir().expect("temp dir");
2758        duplicate_export_project_at(project.path());
2759        project
2760    }
2761
2762    fn duplicate_export_project_at(root: &Path) {
2763        std::fs::create_dir_all(root.join("src")).expect("src dir");
2764        write_json(
2765            root.join("package.json"),
2766            r#"{"name":"api-duplicate-export","main":"src/index.ts"}"#,
2767        );
2768        std::fs::write(root.join("src/index.ts"), "import './a';\nimport './b';\n").expect("entry");
2769        std::fs::write(root.join("src/a.ts"), "export const Button = 1;\n").expect("a");
2770        std::fs::write(root.join("src/b.ts"), "export const Button = 2;\n").expect("b");
2771    }
2772
2773    fn unused_export_names(json: &serde_json::Value) -> Vec<&str> {
2774        json["unused_exports"]
2775            .as_array()
2776            .expect("unused exports array")
2777            .iter()
2778            .map(|item| {
2779                item["name"]
2780                    .as_str()
2781                    .or_else(|| item["export_name"].as_str())
2782                    .expect("unused export name")
2783            })
2784            .collect()
2785    }
2786
2787    fn write_workspace(root: &Path, relative: &str, name: &str) {
2788        let dir = root.join(relative);
2789        std::fs::create_dir_all(&dir).expect("workspace dir");
2790        write_json(dir.join("package.json"), &format!(r#"{{"name":"{name}"}}"#));
2791    }
2792
2793    fn write_json(path: PathBuf, json: &str) {
2794        std::fs::write(path, json).expect("json file");
2795    }
2796}