Skip to main content

fallow_core/
lib.rs

1//! fallow-core is the internal implementation crate behind the `fallow`
2//! analyzer. External embedders should consume the curated programmatic
3//! surface at `fallow_cli::programmatic` (e.g. `detect_dead_code`,
4//! `detect_boundary_violations`, `detect_duplication`, `compute_complexity`,
5//! `compute_health`); each returns a `serde_json::Value` matching the CLI's
6//! `--format json` shape plus structured `ProgrammaticError` with the CLI's
7//! exit-code ladder. See `decisions/008-fallow-core-internal-policy.md` for
8//! the policy, and `docs/fallow-core-migration.md` for the function-by-function
9//! migration map. Items in this crate may change in any release, including
10//! patch releases; a subsequent minor will flip `publish = false` so the crate
11//! is no longer fetchable from crates.io.
12
13#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
14#![cfg_attr(
15    test,
16    allow(
17        clippy::unwrap_used,
18        clippy::expect_used,
19        reason = "tests use unwrap and expect to keep fixture setup concise"
20    )
21)]
22
23pub mod analyze;
24pub mod cache;
25pub mod changed_files;
26pub mod churn;
27pub mod cross_reference;
28pub mod discover;
29pub mod duplicates;
30pub(crate) mod errors;
31mod external_style_usage;
32pub mod extract;
33pub mod git_env;
34mod package_assets;
35pub mod plugins;
36pub(crate) mod progress;
37pub mod results;
38pub(crate) mod scripts;
39pub(crate) mod spawn;
40pub mod suppress;
41pub mod trace;
42
43pub use fallow_graph::graph;
44pub use fallow_graph::project;
45pub use fallow_graph::resolve;
46
47use std::path::{Path, PathBuf};
48use std::time::Instant;
49
50use errors::FallowError;
51use fallow_config::{
52    EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces,
53    find_undeclared_workspaces_with_ignores,
54};
55use rayon::prelude::*;
56use results::AnalysisResults;
57use rustc_hash::FxHashSet;
58use trace::PipelineTimings;
59
60const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
61type LoadedWorkspacePackage<'a> = (&'a fallow_config::WorkspaceInfo, PackageJson);
62
63fn record_graph_package_usage(
64    graph: &mut graph::ModuleGraph,
65    package_name: &str,
66    file_id: discover::FileId,
67    is_type_only: bool,
68) {
69    graph
70        .package_usage
71        .entry(package_name.to_owned())
72        .or_default()
73        .push(file_id);
74    if is_type_only {
75        graph
76            .type_only_package_usage
77            .entry(package_name.to_owned())
78            .or_default()
79            .push(file_id);
80    }
81}
82
83fn workspace_package_name<'a>(
84    source: &str,
85    workspace_names: &'a FxHashSet<&str>,
86) -> Option<&'a str> {
87    if !resolve::is_bare_specifier(source) {
88        return None;
89    }
90    let package_name = resolve::extract_package_name(source);
91    workspace_names.get(package_name.as_str()).copied()
92}
93
94fn credit_workspace_package_usage(
95    graph: &mut graph::ModuleGraph,
96    resolved: &[resolve::ResolvedModule],
97    workspaces: &[fallow_config::WorkspaceInfo],
98) {
99    if workspaces.is_empty() {
100        return;
101    }
102
103    let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
104    for module in resolved {
105        for import in module.all_resolved_imports() {
106            if matches!(import.target, resolve::ResolveResult::InternalModule(_))
107                && let Some(package_name) =
108                    workspace_package_name(&import.info.source, &workspace_names)
109            {
110                record_graph_package_usage(
111                    graph,
112                    package_name,
113                    module.file_id,
114                    import.info.is_type_only,
115                );
116            }
117        }
118
119        for re_export in &module.re_exports {
120            if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
121                && let Some(package_name) =
122                    workspace_package_name(&re_export.info.source, &workspace_names)
123            {
124                record_graph_package_usage(
125                    graph,
126                    package_name,
127                    module.file_id,
128                    re_export.info.is_type_only,
129                );
130            }
131        }
132    }
133}
134
135/// Result of the full analysis pipeline, including optional performance timings.
136pub struct AnalysisOutput {
137    pub results: AnalysisResults,
138    pub timings: Option<PipelineTimings>,
139    pub graph: Option<graph::ModuleGraph>,
140    /// Parsed modules from the pipeline, available when `retain_modules` is true.
141    /// Used by the combined command to share a single parse across dead-code and health.
142    pub modules: Option<Vec<extract::ModuleInfo>>,
143    /// Discovered files from the pipeline, available when `retain_modules` is true.
144    pub files: Option<Vec<discover::DiscoveredFile>>,
145    /// Package names invoked from package.json scripts and CI configs, mirroring
146    /// what the unused-deps detector consults. Populated for every pipeline run;
147    /// trace tooling reads it so `trace_dependency` agrees with `unused-deps` on
148    /// "used vs unused" instead of returning false-negatives for script-only deps.
149    pub script_used_packages: rustc_hash::FxHashSet<String>,
150    /// xxh3 content hash of every parsed source file, keyed by absolute path.
151    /// Used by `fallow fix` to detect on-disk drift between the in-process
152    /// analysis read and the per-file write; if the file's current hash
153    /// differs from the captured value, the fix for that file is skipped
154    /// with a clear diagnostic and exit 2. The hash is the same value
155    /// extract/cache uses for cache invalidation, so a cached parse contributes
156    /// the same hash as a fresh parse. Roughly 8 bytes per file (negligible
157    /// memory cost even on 100k-file projects).
158    pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
159}
160
161/// Update cache: write freshly parsed modules and refresh stale mtime/size entries.
162fn update_cache(
163    store: &mut cache::CacheStore,
164    modules: &[extract::ModuleInfo],
165    files: &[discover::DiscoveredFile],
166) {
167    for module in modules {
168        if let Some(file) = files.get(module.file_id.0 as usize) {
169            let (mt, sz) = file_mtime_and_size(&file.path);
170            if let Some(cached) = store.get_by_path_only(&file.path)
171                && cached.content_hash == module.content_hash
172            {
173                if cached.mtime_secs != mt || cached.file_size != sz {
174                    let preserved_last_access = cached.last_access_secs;
175                    let mut refreshed = cache::module_to_cached(module, mt, sz);
176                    refreshed.last_access_secs = preserved_last_access;
177                    store.insert(&file.path, refreshed);
178                }
179                continue;
180            }
181            store.insert(&file.path, cache::module_to_cached(module, mt, sz));
182        }
183    }
184    store.retain_paths(files);
185}
186
187/// Resolve `config.cache_max_size_mb` into bytes, falling back to the
188/// extract crate's `DEFAULT_CACHE_MAX_SIZE`. Lives at this layer (not on
189/// `ResolvedConfig`) because `fallow-config` does not depend on
190/// `fallow-extract`; the bytes conversion is owned by the cache callsite.
191/// Public so CLI subcommands that load the cache directly (`flags`,
192/// `health`, `coverage analyze`) can call it without re-deriving the
193/// same fallback policy.
194#[must_use]
195pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
196    config
197        .cache_max_size_mb
198        .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
199            (mb as usize).saturating_mul(1024 * 1024)
200        })
201}
202
203/// Extract mtime (seconds since epoch) and file size from a path.
204fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
205    std::fs::metadata(path).map_or((0, 0), |m| {
206        let mt = m
207            .modified()
208            .ok()
209            .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
210            .map_or(0, |d| d.as_secs());
211        (mt, m.len())
212    })
213}
214
215fn format_undeclared_workspace_warning(
216    root: &Path,
217    undeclared: &[fallow_config::WorkspaceDiagnostic],
218) -> Option<String> {
219    if undeclared.is_empty() {
220        return None;
221    }
222
223    let preview = undeclared
224        .iter()
225        .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
226        .map(|diag| {
227            diag.path
228                .strip_prefix(root)
229                .unwrap_or(&diag.path)
230                .display()
231                .to_string()
232                .replace('\\', "/")
233        })
234        .collect::<Vec<_>>();
235    let remaining = undeclared
236        .len()
237        .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
238    let tail = if remaining > 0 {
239        format!(" (and {remaining} more)")
240    } else {
241        String::new()
242    };
243    let noun = if undeclared.len() == 1 {
244        "directory with package.json is"
245    } else {
246        "directories with package.json are"
247    };
248    let guidance = if undeclared.len() == 1 {
249        "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
250    } else {
251        "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
252    };
253
254    Some(format!(
255        "{} {} not declared as {}: {}{}. {}",
256        undeclared.len(),
257        noun,
258        if undeclared.len() == 1 {
259            "a workspace"
260        } else {
261            "workspaces"
262        },
263        preview.join(", "),
264        tail,
265        guidance
266    ))
267}
268
269fn warn_undeclared_workspaces(
270    root: &Path,
271    workspaces_vec: &[fallow_config::WorkspaceInfo],
272    ignore_patterns: &globset::GlobSet,
273    quiet: bool,
274) {
275    let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
276    if undeclared.is_empty() {
277        return;
278    }
279
280    let existing = fallow_config::workspace_diagnostics_for(root);
281    let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
282        .iter()
283        .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
284        .collect();
285    let undeclared: Vec<_> = undeclared
286        .into_iter()
287        .filter(|diag| {
288            let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
289            !already_flagged.contains(&canonical)
290        })
291        .collect();
292    if undeclared.is_empty() {
293        return;
294    }
295
296    fallow_config::append_workspace_diagnostics(root, undeclared.clone());
297
298    if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
299        tracing::warn!("{message}");
300    }
301}
302
303/// Run the full analysis pipeline.
304///
305/// # Errors
306///
307/// Returns an error if file discovery, parsing, or analysis fails.
308#[deprecated(
309    since = "2.76.0",
310    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
311)]
312pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
313    let output = analyze_full(config, false, false, false, false)?;
314    Ok(output.results)
315}
316
317/// Run the full analysis pipeline with export usage collection (for LSP Code Lens).
318///
319/// # Errors
320///
321/// Returns an error if file discovery, parsing, or analysis fails.
322#[deprecated(
323    since = "2.76.0",
324    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: export-usage collection is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
325)]
326pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
327    let output = analyze_full(config, false, true, false, false)?;
328    Ok(output.results)
329}
330
331/// Run the full analysis pipeline with optional performance timings and graph retention.
332///
333/// # Errors
334///
335/// Returns an error if file discovery, parsing, or analysis fails.
336#[deprecated(
337    since = "2.76.0",
338    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: trace timings are not exposed in the programmatic surface today; use `fallow check --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
339)]
340pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
341    analyze_full(config, true, false, false, false)
342}
343
344/// Run the full analysis pipeline and return the full `AnalysisOutput`, including
345/// `file_hashes` (used by `fallow fix` to detect on-disk drift between analysis
346/// and per-file write). Graphs and modules are NOT retained; the only difference
347/// from `analyze` is that the caller can access `AnalysisOutput.file_hashes`.
348///
349/// # Errors
350///
351/// Returns an error if file discovery, parsing, or analysis fails.
352#[deprecated(
353    since = "2.76.0",
354    note = "fallow_core is internal; the CLI fix command uses this via the workspace path dependency. External embedders should use fallow_cli::programmatic::detect_dead_code. See docs/fallow-core-migration.md and ADR-008."
355)]
356pub fn analyze_with_file_hashes(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
357    analyze_full(config, false, false, false, false)
358}
359
360/// Run the full analysis pipeline, retaining parsed modules and discovered files.
361///
362/// Used by the combined command to share a single parse across dead-code and health.
363/// When `need_complexity` is true, the `ComplexityVisitor` runs during parsing so
364/// the returned modules contain per-function complexity data.
365///
366/// # Errors
367///
368/// Returns an error if file discovery, parsing, or analysis fails.
369#[deprecated(
370    since = "2.76.0",
371    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: combined-mode module retention is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
372)]
373pub fn analyze_retaining_modules(
374    config: &ResolvedConfig,
375    need_complexity: bool,
376    retain_graph: bool,
377) -> Result<AnalysisOutput, FallowError> {
378    analyze_full(config, retain_graph, false, need_complexity, true)
379}
380
381/// Run the analysis pipeline using pre-parsed modules, skipping the parsing stage.
382///
383/// This avoids re-parsing files when the caller already has a `ParseResult` (e.g., from
384/// `fallow_core::extract::parse_all_files`). Discovery, plugins, scripts, entry points,
385/// import resolution, graph construction, and dead code detection still run normally.
386/// The graph is always retained (needed for file scores).
387///
388/// # Errors
389///
390/// Returns an error if discovery, graph construction, or analysis fails.
391#[allow(
392    clippy::too_many_lines,
393    reason = "pipeline orchestration stays easier to audit in one place"
394)]
395#[deprecated(
396    since = "2.76.0",
397    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: pre-parsed module reuse is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
398)]
399pub fn analyze_with_parse_result(
400    config: &ResolvedConfig,
401    modules: &[extract::ModuleInfo],
402) -> Result<AnalysisOutput, FallowError> {
403    let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
404    let pipeline_start = Instant::now();
405
406    let show_progress = !config.quiet
407        && std::io::IsTerminal::is_terminal(&std::io::stderr())
408        && matches!(
409            config.output,
410            fallow_config::OutputFormat::Human
411                | fallow_config::OutputFormat::Compact
412                | fallow_config::OutputFormat::Markdown
413        );
414    let progress = progress::AnalysisProgress::new(show_progress);
415
416    if !config.root.join("node_modules").is_dir() {
417        tracing::warn!(
418            "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
419        );
420    }
421
422    let t = Instant::now();
423    let workspaces_vec = discover_workspaces(&config.root);
424    let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
425    if !workspaces_vec.is_empty() {
426        tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
427    }
428
429    warn_undeclared_workspaces(
430        &config.root,
431        &workspaces_vec,
432        &config.ignore_patterns,
433        config.quiet,
434    );
435    let root_pkg = load_root_package_json(config);
436    let discovery_hidden_dir_scopes =
437        discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
438
439    let t = Instant::now();
440    progress.set_stage("discovering files...");
441    let discovered_files =
442        discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
443    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
444
445    let project = project::ProjectState::new(discovered_files, workspaces_vec);
446    let files = project.files();
447    let workspaces = project.workspaces();
448    let workspace_pkgs = load_workspace_packages(workspaces);
449
450    let t = Instant::now();
451    progress.set_stage("detecting plugins...");
452    let mut plugin_result = run_plugins(
453        config,
454        files,
455        workspaces,
456        root_pkg.as_ref(),
457        &workspace_pkgs,
458    );
459    let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
460
461    let t = Instant::now();
462    analyze_all_scripts(
463        config,
464        workspaces,
465        root_pkg.as_ref(),
466        &workspace_pkgs,
467        &mut plugin_result,
468    );
469    let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
470
471    let t = Instant::now();
472    let entry_points = discover_all_entry_points(
473        config,
474        files,
475        workspaces,
476        root_pkg.as_ref(),
477        &workspace_pkgs,
478        &plugin_result,
479    );
480    let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
481
482    let ep_summary = summarize_entry_points(&entry_points.all);
483
484    let t = Instant::now();
485    progress.set_stage("resolving imports...");
486    let mut resolved = resolve::resolve_all_imports(
487        modules,
488        files,
489        workspaces,
490        &plugin_result.active_plugins,
491        &plugin_result.path_aliases,
492        &plugin_result.auto_imports,
493        &plugin_result.scss_include_paths,
494        &plugin_result.static_dir_mappings,
495        &config.root,
496        &config.resolve.conditions,
497    );
498    external_style_usage::augment_external_style_package_usage(
499        &mut resolved,
500        config,
501        workspaces,
502        &plugin_result,
503    );
504    let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
505
506    let t = Instant::now();
507    progress.set_stage("building module graph...");
508    let mut graph = graph::ModuleGraph::build_with_reachability_roots(
509        &resolved,
510        &entry_points.all,
511        &entry_points.runtime,
512        &entry_points.test,
513        files,
514    );
515    credit_workspace_package_usage(&mut graph, &resolved, workspaces);
516    let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
517
518    let t = Instant::now();
519    progress.set_stage("analyzing...");
520    #[expect(
521        deprecated,
522        reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
523    )]
524    let mut result = analyze::find_dead_code_full(
525        &graph,
526        config,
527        &resolved,
528        Some(&plugin_result),
529        workspaces,
530        modules,
531        false,
532    );
533    let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
534    progress.finish();
535
536    result.entry_point_summary = Some(ep_summary);
537
538    let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
539
540    tracing::debug!(
541        "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
542         │  discover files:   {:>8.1}ms  ({} files)\n\
543         │  workspaces:       {:>8.1}ms\n\
544         │  plugins:          {:>8.1}ms\n\
545         │  script analysis:  {:>8.1}ms\n\
546         │  parse/extract:    SKIPPED (reused {} modules)\n\
547         │  entry points:     {:>8.1}ms  ({} entries)\n\
548         │  resolve imports:  {:>8.1}ms\n\
549         │  build graph:      {:>8.1}ms\n\
550         │  analyze:          {:>8.1}ms\n\
551         │  ────────────────────────────────────────────\n\
552         │  TOTAL:            {:>8.1}ms\n\
553         └─────────────────────────────────────────────────",
554        discover_ms,
555        files.len(),
556        workspaces_ms,
557        plugins_ms,
558        scripts_ms,
559        modules.len(),
560        entry_points_ms,
561        entry_points.all.len(),
562        resolve_ms,
563        graph_ms,
564        analyze_ms,
565        total_ms,
566    );
567
568    let timings = Some(PipelineTimings {
569        discover_files_ms: discover_ms,
570        file_count: files.len(),
571        workspaces_ms,
572        workspace_count: workspaces.len(),
573        plugins_ms,
574        script_analysis_ms: scripts_ms,
575        parse_extract_ms: 0.0, // Skipped: modules were reused
576        parse_cpu_ms: 0.0,     // Skipped: modules were reused
577        module_count: modules.len(),
578        cache_hits: 0,
579        cache_misses: 0,
580        cache_update_ms: 0.0,
581        entry_points_ms,
582        entry_point_count: entry_points.all.len(),
583        resolve_imports_ms: resolve_ms,
584        build_graph_ms: graph_ms,
585        analyze_ms,
586        duplication_ms: None,
587        total_ms,
588    });
589
590    let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
591        .iter()
592        .filter_map(|module| {
593            files
594                .get(module.file_id.0 as usize)
595                .map(|file| (file.path.clone(), module.content_hash))
596        })
597        .collect();
598
599    Ok(AnalysisOutput {
600        results: result,
601        timings,
602        graph: Some(graph),
603        modules: None,
604        files: None,
605        script_used_packages: plugin_result.script_used_packages.clone(),
606        file_hashes,
607    })
608}
609
610#[expect(
611    clippy::unnecessary_wraps,
612    reason = "Result kept for future error handling"
613)]
614#[expect(
615    clippy::too_many_lines,
616    reason = "main pipeline function; sequential phases are held together for clarity"
617)]
618fn analyze_full(
619    config: &ResolvedConfig,
620    retain: bool,
621    collect_usages: bool,
622    need_complexity: bool,
623    retain_modules: bool,
624) -> Result<AnalysisOutput, FallowError> {
625    let _span = tracing::info_span!("fallow_analyze").entered();
626    let pipeline_start = Instant::now();
627
628    let show_progress = !config.quiet
629        && std::io::IsTerminal::is_terminal(&std::io::stderr())
630        && matches!(
631            config.output,
632            fallow_config::OutputFormat::Human
633                | fallow_config::OutputFormat::Compact
634                | fallow_config::OutputFormat::Markdown
635        );
636    let progress = progress::AnalysisProgress::new(show_progress);
637
638    if !config.root.join("node_modules").is_dir() {
639        tracing::warn!(
640            "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
641        );
642    }
643
644    let t = Instant::now();
645    let workspaces_vec = discover_workspaces(&config.root);
646    let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
647    if !workspaces_vec.is_empty() {
648        tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
649    }
650
651    warn_undeclared_workspaces(
652        &config.root,
653        &workspaces_vec,
654        &config.ignore_patterns,
655        config.quiet,
656    );
657    let root_pkg = load_root_package_json(config);
658    let discovery_hidden_dir_scopes =
659        discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
660
661    let t = Instant::now();
662    progress.set_stage("discovering files...");
663    let discovered_files =
664        discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
665    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
666
667    let project = project::ProjectState::new(discovered_files, workspaces_vec);
668    let files = project.files();
669    let workspaces = project.workspaces();
670    let workspace_pkgs = load_workspace_packages(workspaces);
671
672    let t = Instant::now();
673    progress.set_stage("detecting plugins...");
674    let mut plugin_result = run_plugins(
675        config,
676        files,
677        workspaces,
678        root_pkg.as_ref(),
679        &workspace_pkgs,
680    );
681    let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
682
683    let t = Instant::now();
684    analyze_all_scripts(
685        config,
686        workspaces,
687        root_pkg.as_ref(),
688        &workspace_pkgs,
689        &mut plugin_result,
690    );
691    let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
692
693    let t = Instant::now();
694    progress.set_stage(&format!("parsing {} files...", files.len()));
695    let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
696    let mut cache_store = if config.no_cache {
697        None
698    } else {
699        cache::CacheStore::load(
700            &config.cache_dir,
701            config.cache_config_hash,
702            cache_max_size_bytes,
703        )
704    };
705
706    let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
707    let modules = parse_result.modules;
708    let cache_hits = parse_result.cache_hits;
709    let cache_misses = parse_result.cache_misses;
710    let parse_cpu_ms = parse_result.parse_cpu_ms;
711    let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
712
713    let t = Instant::now();
714    if !config.no_cache {
715        let store = cache_store.get_or_insert_with(cache::CacheStore::new);
716        update_cache(store, &modules, files);
717        if let Err(e) = store.save(
718            &config.cache_dir,
719            config.cache_config_hash,
720            cache_max_size_bytes,
721        ) {
722            tracing::warn!("Failed to save cache: {e}");
723        }
724    }
725    let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
726
727    let t = Instant::now();
728    let entry_points = discover_all_entry_points(
729        config,
730        files,
731        workspaces,
732        root_pkg.as_ref(),
733        &workspace_pkgs,
734        &plugin_result,
735    );
736    let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
737
738    let t = Instant::now();
739    progress.set_stage("resolving imports...");
740    let mut resolved = resolve::resolve_all_imports(
741        &modules,
742        files,
743        workspaces,
744        &plugin_result.active_plugins,
745        &plugin_result.path_aliases,
746        &plugin_result.auto_imports,
747        &plugin_result.scss_include_paths,
748        &plugin_result.static_dir_mappings,
749        &config.root,
750        &config.resolve.conditions,
751    );
752    external_style_usage::augment_external_style_package_usage(
753        &mut resolved,
754        config,
755        workspaces,
756        &plugin_result,
757    );
758    let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
759
760    let t = Instant::now();
761    progress.set_stage("building module graph...");
762    let mut graph = graph::ModuleGraph::build_with_reachability_roots(
763        &resolved,
764        &entry_points.all,
765        &entry_points.runtime,
766        &entry_points.test,
767        files,
768    );
769    credit_workspace_package_usage(&mut graph, &resolved, workspaces);
770    let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
771
772    let ep_summary = summarize_entry_points(&entry_points.all);
773
774    let t = Instant::now();
775    progress.set_stage("analyzing...");
776    #[expect(
777        deprecated,
778        reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
779    )]
780    let mut result = analyze::find_dead_code_full(
781        &graph,
782        config,
783        &resolved,
784        Some(&plugin_result),
785        workspaces,
786        &modules,
787        collect_usages,
788    );
789    let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
790    progress.finish();
791
792    result.entry_point_summary = Some(ep_summary);
793
794    let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
795
796    let cache_summary = if cache_hits > 0 {
797        format!(" ({cache_hits} cached, {cache_misses} parsed)")
798    } else {
799        String::new()
800    };
801
802    tracing::debug!(
803        "\n┌─ Pipeline Profile ─────────────────────────────\n\
804         │  discover files:   {:>8.1}ms  ({} files)\n\
805         │  workspaces:       {:>8.1}ms\n\
806         │  plugins:          {:>8.1}ms\n\
807         │  script analysis:  {:>8.1}ms\n\
808         │  parse/extract:    {:>8.1}ms  ({} modules{})\n\
809         │  cache update:     {:>8.1}ms\n\
810         │  entry points:     {:>8.1}ms  ({} entries)\n\
811         │  resolve imports:  {:>8.1}ms\n\
812         │  build graph:      {:>8.1}ms\n\
813         │  analyze:          {:>8.1}ms\n\
814         │  ────────────────────────────────────────────\n\
815         │  TOTAL:            {:>8.1}ms\n\
816         └─────────────────────────────────────────────────",
817        discover_ms,
818        files.len(),
819        workspaces_ms,
820        plugins_ms,
821        scripts_ms,
822        parse_ms,
823        modules.len(),
824        cache_summary,
825        cache_ms,
826        entry_points_ms,
827        entry_points.all.len(),
828        resolve_ms,
829        graph_ms,
830        analyze_ms,
831        total_ms,
832    );
833
834    let timings = if retain {
835        Some(PipelineTimings {
836            discover_files_ms: discover_ms,
837            file_count: files.len(),
838            workspaces_ms,
839            workspace_count: workspaces.len(),
840            plugins_ms,
841            script_analysis_ms: scripts_ms,
842            parse_extract_ms: parse_ms,
843            parse_cpu_ms,
844            module_count: modules.len(),
845            cache_hits,
846            cache_misses,
847            cache_update_ms: cache_ms,
848            entry_points_ms,
849            entry_point_count: entry_points.all.len(),
850            resolve_imports_ms: resolve_ms,
851            build_graph_ms: graph_ms,
852            analyze_ms,
853            duplication_ms: None,
854            total_ms,
855        })
856    } else {
857        None
858    };
859
860    let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
861        .iter()
862        .filter_map(|module| {
863            files
864                .get(module.file_id.0 as usize)
865                .map(|file| (file.path.clone(), module.content_hash))
866        })
867        .collect();
868
869    Ok(AnalysisOutput {
870        results: result,
871        timings,
872        graph: if retain { Some(graph) } else { None },
873        modules: if retain_modules { Some(modules) } else { None },
874        files: if retain_modules {
875            Some(files.to_vec())
876        } else {
877            None
878        },
879        script_used_packages: plugin_result.script_used_packages,
880        file_hashes,
881    })
882}
883
884/// Analyze package.json scripts from root and all workspace packages.
885///
886/// Populates the plugin result with script-used packages and config file
887/// entry patterns. Also scans CI config files for binary invocations.
888fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
889    PackageJson::load(&config.root.join("package.json")).ok()
890}
891
892fn load_workspace_packages(
893    workspaces: &[fallow_config::WorkspaceInfo],
894) -> Vec<LoadedWorkspacePackage<'_>> {
895    workspaces
896        .iter()
897        .filter_map(|ws| {
898            PackageJson::load(&ws.root.join("package.json"))
899                .ok()
900                .map(|pkg| (ws, pkg))
901        })
902        .collect()
903}
904
905fn analyze_all_scripts(
906    config: &ResolvedConfig,
907    workspaces: &[fallow_config::WorkspaceInfo],
908    root_pkg: Option<&PackageJson>,
909    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
910    plugin_result: &mut plugins::AggregatedPluginResult,
911) {
912    let mut all_dep_names: Vec<String> = Vec::new();
913    if let Some(pkg) = root_pkg {
914        all_dep_names.extend(pkg.all_dependency_names());
915    }
916    for (_, ws_pkg) in workspace_pkgs {
917        all_dep_names.extend(ws_pkg.all_dependency_names());
918    }
919    all_dep_names.sort_unstable();
920    all_dep_names.dedup();
921    let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
922    let mut all_script_names: FxHashSet<String> = FxHashSet::default();
923    if let Some(pkg) = root_pkg
924        && let Some(ref pkg_scripts) = pkg.scripts
925    {
926        all_script_names.extend(pkg_scripts.keys().cloned());
927    }
928    for (_, ws_pkg) in workspace_pkgs {
929        if let Some(ref ws_scripts) = ws_pkg.scripts {
930            all_script_names.extend(ws_scripts.keys().cloned());
931        }
932    }
933
934    let mut nm_roots: Vec<&std::path::Path> = Vec::new();
935    if config.root.join("node_modules").is_dir() {
936        nm_roots.push(&config.root);
937    }
938    for ws in workspaces {
939        if ws.root.join("node_modules").is_dir() {
940            nm_roots.push(&ws.root);
941        }
942    }
943    let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
944
945    if let Some(pkg) = root_pkg
946        && let Some(ref pkg_scripts) = pkg.scripts
947    {
948        let scripts_to_analyze = if config.production {
949            scripts::filter_production_scripts(pkg_scripts)
950        } else {
951            pkg_scripts.clone()
952        };
953        let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
954        let script_analysis = scripts::analyze_scripts_with_dependency_context(
955            &scripts_to_analyze,
956            &config.root,
957            &bin_map,
958            &all_dep_set,
959            &script_names,
960        );
961        plugin_result.script_used_packages = script_analysis.used_packages;
962
963        for config_file in &script_analysis.config_files {
964            plugin_result
965                .discovered_always_used
966                .push((config_file.clone(), "scripts".to_string()));
967        }
968        for entry in &script_analysis.entry_files {
969            if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
970                plugin_result
971                    .entry_patterns
972                    .push((plugins::PathRule::new(pat), "scripts".to_string()));
973            }
974        }
975    }
976    use rayon::prelude::*;
977    type WsScriptOut = (
978        Vec<String>,
979        Vec<(String, String)>,
980        Vec<(plugins::PathRule, String)>,
981    );
982    let ws_results: Vec<WsScriptOut> = workspace_pkgs
983        .par_iter()
984        .map(|(ws, ws_pkg)| {
985            let mut used_packages = Vec::new();
986            let mut discovered_always_used: Vec<(String, String)> = Vec::new();
987            let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
988            if let Some(ref ws_scripts) = ws_pkg.scripts {
989                let scripts_to_analyze = if config.production {
990                    scripts::filter_production_scripts(ws_scripts)
991                } else {
992                    ws_scripts.clone()
993                };
994                let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
995                let ws_analysis = scripts::analyze_scripts_with_dependency_context(
996                    &scripts_to_analyze,
997                    &ws.root,
998                    &bin_map,
999                    &all_dep_set,
1000                    &script_names,
1001                );
1002                used_packages.extend(ws_analysis.used_packages);
1003
1004                let ws_prefix = ws
1005                    .root
1006                    .strip_prefix(&config.root)
1007                    .unwrap_or(&ws.root)
1008                    .to_string_lossy();
1009                for config_file in &ws_analysis.config_files {
1010                    discovered_always_used
1011                        .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1012                }
1013                for entry in &ws_analysis.entry_files {
1014                    if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1015                        entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1016                    }
1017                }
1018            }
1019            (used_packages, discovered_always_used, entry_patterns)
1020        })
1021        .collect();
1022    for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1023        plugin_result.script_used_packages.extend(used_packages);
1024        plugin_result
1025            .discovered_always_used
1026            .extend(discovered_always_used);
1027        plugin_result.entry_patterns.extend(entry_patterns);
1028    }
1029
1030    let ci_analysis =
1031        scripts::ci::analyze_ci_files(&config.root, &bin_map, &all_dep_set, &all_script_names);
1032    plugin_result
1033        .script_used_packages
1034        .extend(ci_analysis.used_packages);
1035    for entry in &ci_analysis.entry_files {
1036        if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1037            plugin_result
1038                .entry_patterns
1039                .push((plugins::PathRule::new(pat), "scripts".to_string()));
1040        }
1041    }
1042    plugin_result
1043        .entry_point_roles
1044        .entry("scripts".to_string())
1045        .or_insert(EntryPointRole::Support);
1046}
1047
1048/// Discover all entry points from static patterns, workspaces, plugins, and infrastructure.
1049fn discover_all_entry_points(
1050    config: &ResolvedConfig,
1051    files: &[discover::DiscoveredFile],
1052    workspaces: &[fallow_config::WorkspaceInfo],
1053    root_pkg: Option<&PackageJson>,
1054    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1055    plugin_result: &plugins::AggregatedPluginResult,
1056) -> discover::CategorizedEntryPoints {
1057    let mut entry_points = discover::CategorizedEntryPoints::default();
1058    let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1059        config,
1060        files,
1061        root_pkg,
1062        workspaces.is_empty(),
1063    );
1064
1065    let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1066        workspace_pkgs
1067            .iter()
1068            .map(|(ws, pkg)| (ws.root.clone(), pkg))
1069            .collect();
1070
1071    let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1072        .par_iter()
1073        .map(|ws| {
1074            let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1075            discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1076        })
1077        .collect();
1078    let mut skipped_entries = rustc_hash::FxHashMap::default();
1079    entry_points.extend_runtime(root_discovery.entries);
1080    for (path, count) in root_discovery.skipped_entries {
1081        *skipped_entries.entry(path).or_insert(0) += count;
1082    }
1083    let mut ws_entries = Vec::new();
1084    for workspace in workspace_discovery {
1085        ws_entries.extend(workspace.entries);
1086        for (path, count) in workspace.skipped_entries {
1087            *skipped_entries.entry(path).or_insert(0) += count;
1088        }
1089    }
1090    discover::warn_skipped_entry_summary(&skipped_entries);
1091    entry_points.extend_runtime(ws_entries);
1092
1093    let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1094    entry_points.extend(plugin_entries);
1095
1096    let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1097    entry_points.extend_runtime(infra_entries);
1098
1099    if !config.dynamically_loaded.is_empty() {
1100        let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1101        entry_points.extend_runtime(dynamic_entries);
1102    }
1103
1104    entry_points.dedup()
1105}
1106
1107/// Summarize entry points by source category for user-facing output.
1108fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1109    let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1110    for ep in entry_points {
1111        let category = match &ep.source {
1112            discover::EntryPointSource::PackageJsonMain
1113            | discover::EntryPointSource::PackageJsonModule
1114            | discover::EntryPointSource::PackageJsonExports
1115            | discover::EntryPointSource::PackageJsonBin
1116            | discover::EntryPointSource::PackageJsonScript => "package.json",
1117            discover::EntryPointSource::Plugin { .. } => "plugin",
1118            discover::EntryPointSource::TestFile => "test file",
1119            discover::EntryPointSource::DefaultIndex => "default index",
1120            discover::EntryPointSource::ManualEntry => "manual entry",
1121            discover::EntryPointSource::InfrastructureConfig => "config",
1122            discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1123        };
1124        *counts.entry(category.to_string()).or_insert(0) += 1;
1125    }
1126    let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1127    by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1128    results::EntryPointSummary {
1129        total: entry_points.len(),
1130        by_source,
1131    }
1132}
1133
1134fn append_package_file_asset_patterns(
1135    result: &mut plugins::AggregatedPluginResult,
1136    prefix: &str,
1137    pkg: &PackageJson,
1138) {
1139    let prefix = prefix.trim_matches('/');
1140    for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1141        let pattern = if prefix.is_empty() {
1142            pattern
1143        } else {
1144            format!("{prefix}/{pattern}")
1145        };
1146        result
1147            .discovered_always_used
1148            .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1149    }
1150}
1151
1152fn append_workspace_package_file_asset_patterns(
1153    result: &mut plugins::AggregatedPluginResult,
1154    config: &ResolvedConfig,
1155    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1156) {
1157    for (ws, ws_pkg) in workspace_pkgs {
1158        let ws_prefix = ws
1159            .root
1160            .strip_prefix(&config.root)
1161            .unwrap_or(&ws.root)
1162            .to_string_lossy()
1163            .replace('\\', "/");
1164        append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1165    }
1166}
1167
1168/// Run plugins for root project and all workspace packages.
1169fn run_plugins(
1170    config: &ResolvedConfig,
1171    files: &[discover::DiscoveredFile],
1172    workspaces: &[fallow_config::WorkspaceInfo],
1173    root_pkg: Option<&PackageJson>,
1174    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1175) -> plugins::AggregatedPluginResult {
1176    let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1177    let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1178    let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1179    let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1180        .iter()
1181        .map(std::path::PathBuf::as_path)
1182        .collect();
1183
1184    let mut result = root_pkg.map_or_else(plugins::AggregatedPluginResult::default, |pkg| {
1185        registry.run_with_search_roots(
1186            pkg,
1187            &config.root,
1188            &file_paths,
1189            &root_config_search_root_refs,
1190            config.production,
1191        )
1192    });
1193    if let Some(pkg) = root_pkg {
1194        append_package_file_asset_patterns(&mut result, "", pkg);
1195    }
1196
1197    if workspaces.is_empty() {
1198        gate_auto_import_entry_patterns(&mut result, config, workspaces);
1199        return result;
1200    }
1201
1202    append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1203
1204    let root_active_plugins: rustc_hash::FxHashSet<&str> =
1205        result.active_plugins.iter().map(String::as_str).collect();
1206
1207    let precompiled_matchers = registry.precompile_config_matchers();
1208    let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1209
1210    let ws_results: Vec<_> = workspace_pkgs
1211        .par_iter()
1212        .zip(workspace_relative_files.par_iter())
1213        .filter_map(|((ws, ws_pkg), relative_files)| {
1214            let ws_result = registry.run_workspace_fast(
1215                ws_pkg,
1216                &ws.root,
1217                &config.root,
1218                &precompiled_matchers,
1219                relative_files,
1220                &root_active_plugins,
1221                config.production,
1222            );
1223            if ws_result.active_plugins.is_empty() {
1224                return None;
1225            }
1226            let ws_prefix = ws
1227                .root
1228                .strip_prefix(&config.root)
1229                .unwrap_or(&ws.root)
1230                .to_string_lossy()
1231                .into_owned();
1232            Some((ws_result, ws_prefix))
1233        })
1234        .collect();
1235
1236    for (mut ws_result, ws_prefix) in ws_results {
1237        ws_result.apply_workspace_prefix(&ws_prefix);
1238        ws_result.config_patterns.clear();
1239        ws_result.script_used_packages.clear();
1240        result.merge_into(ws_result);
1241    }
1242
1243    gate_auto_import_entry_patterns(&mut result, config, workspaces);
1244
1245    result
1246}
1247
1248/// When `autoImports` is enabled, drop the modeled Nuxt convention entry
1249/// patterns so genuinely-unreferenced convention files are reported as
1250/// `unused-file`. Component and script fallbacks have separate conservative
1251/// config guards because custom `components:` and `imports:` settings affect
1252/// different convention surfaces.
1253fn gate_auto_import_entry_patterns(
1254    result: &mut plugins::AggregatedPluginResult,
1255    config: &ResolvedConfig,
1256    workspaces: &[fallow_config::WorkspaceInfo],
1257) {
1258    if !config.auto_imports {
1259        return;
1260    }
1261    if !result.active_plugins.iter().any(|name| name == "nuxt") {
1262        return;
1263    }
1264    let components_custom = plugins::nuxt::config_declares_components(&config.root)
1265        || workspaces
1266            .iter()
1267            .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
1268    let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
1269        || workspaces
1270            .iter()
1271            .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
1272    result.entry_patterns.retain(|(rule, plugin)| {
1273        if plugin != "nuxt" {
1274            return true;
1275        }
1276        if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
1277            return false;
1278        }
1279        if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
1280            return false;
1281        }
1282        true
1283    });
1284}
1285
1286fn bucket_files_by_workspace(
1287    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1288    file_paths: &[std::path::PathBuf],
1289) -> Vec<Vec<(std::path::PathBuf, String)>> {
1290    let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1291
1292    for file_path in file_paths {
1293        for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1294            if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1295                buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1296                break;
1297            }
1298        }
1299    }
1300
1301    buckets
1302}
1303
1304fn collect_config_search_roots(
1305    root: &Path,
1306    file_paths: &[std::path::PathBuf],
1307) -> Vec<std::path::PathBuf> {
1308    let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1309    roots.insert(root.to_path_buf());
1310
1311    for file_path in file_paths {
1312        let mut current = file_path.parent();
1313        while let Some(dir) = current {
1314            if !dir.starts_with(root) {
1315                break;
1316            }
1317            roots.insert(dir.to_path_buf());
1318            if dir == root {
1319                break;
1320            }
1321            current = dir.parent();
1322        }
1323    }
1324
1325    let mut roots_vec: Vec<_> = roots.into_iter().collect();
1326    roots_vec.sort();
1327    roots_vec
1328}
1329
1330/// Run analysis on a project directory (with export usages for LSP Code Lens).
1331///
1332/// # Errors
1333///
1334/// Returns an error if config loading, file discovery, parsing, or analysis fails.
1335#[deprecated(
1336    since = "2.76.0",
1337    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead (build a `DeadCodeOptions { analysis: AnalysisOptions { root, ..default() }, ..default() }`). See docs/fallow-core-migration.md and ADR-008."
1338)]
1339pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1340    let config = default_config(root);
1341    #[expect(
1342        deprecated,
1343        reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1344    )]
1345    analyze_with_usages(&config)
1346}
1347
1348/// Resolve the analysis config for a project, mirroring the CLI's `--config`
1349/// behavior when `config_path` is provided.
1350///
1351/// # Errors
1352///
1353/// Returns an error when an explicit config cannot be loaded or automatic
1354/// config discovery finds an invalid config.
1355pub fn config_for_project(
1356    root: &Path,
1357    config_path: Option<&Path>,
1358) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1359    let user_config = if let Some(path) = config_path {
1360        Some((
1361            fallow_config::FallowConfig::load(path)
1362                .map_err(|e| FallowError::config(format!("{e:#}")))?,
1363            path.to_path_buf(),
1364        ))
1365    } else {
1366        fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1367    };
1368
1369    let config = match user_config {
1370        Some((mut config, path)) => {
1371            let dead_code_production = config
1372                .production
1373                .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1374            config.production = dead_code_production.into();
1375            config
1376                .validate_resolved_boundaries(root)
1377                .map_err(|errors| {
1378                    let joined = errors
1379                        .iter()
1380                        .map(ToString::to_string)
1381                        .collect::<Vec<_>>()
1382                        .join("\n  - ");
1383                    FallowError::config(format!("invalid boundary configuration:\n  - {joined}"))
1384                })?;
1385            (
1386                config.resolve(
1387                    root.to_path_buf(),
1388                    fallow_config::OutputFormat::Human,
1389                    num_cpus(),
1390                    false,
1391                    true, // quiet: LSP/programmatic callers don't need progress bars
1392                    None, // LSP/programmatic embedders use the default cache cap
1393                ),
1394                Some(path),
1395            )
1396        }
1397        None => (
1398            fallow_config::FallowConfig::default().resolve(
1399                root.to_path_buf(),
1400                fallow_config::OutputFormat::Human,
1401                num_cpus(),
1402                false,
1403                true,
1404                None,
1405            ),
1406            None,
1407        ),
1408    };
1409
1410    Ok(config)
1411}
1412
1413/// Create a default config for a project root.
1414///
1415/// `analyze_project` is the dead-code entry point used by the LSP and other
1416/// programmatic embedders. When the loaded config uses the per-analysis
1417/// production form (`production: { deadCode: true, ... }`), the production
1418/// flag must be flattened to the dead-code analysis here. Otherwise
1419/// `ResolvedConfig::resolve` calls `.global()` which returns false for the
1420/// per-analysis variant and the production-mode rule overrides
1421/// (`unused_dev_dependencies: off`, etc.) plus `resolved.production = true`
1422/// are silently dropped.
1423pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
1424    config_for_project(root, None).map_or_else(
1425        |_| {
1426            fallow_config::FallowConfig::default().resolve(
1427                root.to_path_buf(),
1428                fallow_config::OutputFormat::Human,
1429                num_cpus(),
1430                false,
1431                true,
1432                None,
1433            )
1434        },
1435        |(config, _)| config,
1436    )
1437}
1438
1439fn num_cpus() -> usize {
1440    std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::{
1446        bucket_files_by_workspace, collect_config_search_roots,
1447        format_undeclared_workspace_warning, warn_undeclared_workspaces,
1448    };
1449    use std::path::{Path, PathBuf};
1450
1451    use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
1452
1453    fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
1454        WorkspaceDiagnostic::new(
1455            root,
1456            root.join(relative),
1457            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1458        )
1459    }
1460
1461    #[test]
1462    fn undeclared_workspace_warning_is_singular_for_one_path() {
1463        let root = Path::new("/repo");
1464        let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
1465            .expect("warning should be rendered");
1466
1467        assert_eq!(
1468            warning,
1469            "1 directory with package.json is not declared as a workspace: packages/api. Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
1470        );
1471    }
1472
1473    #[test]
1474    fn undeclared_workspace_warning_summarizes_many_paths() {
1475        let root = PathBuf::from("/repo");
1476        let diagnostics = [
1477            "examples/a",
1478            "examples/b",
1479            "examples/c",
1480            "examples/d",
1481            "examples/e",
1482            "examples/f",
1483        ]
1484        .into_iter()
1485        .map(|path| diag(&root, path))
1486        .collect::<Vec<_>>();
1487
1488        let warning = format_undeclared_workspace_warning(&root, &diagnostics)
1489            .expect("warning should be rendered");
1490
1491        assert_eq!(
1492            warning,
1493            "6 directories with package.json are not declared as workspaces: examples/a, examples/b, examples/c, examples/d, examples/e (and 1 more). Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
1494        );
1495    }
1496
1497    #[test]
1498    fn collect_config_search_roots_includes_file_ancestors_once() {
1499        let root = PathBuf::from("/repo");
1500        let search_roots = collect_config_search_roots(
1501            &root,
1502            &[
1503                root.join("apps/query/src/main.ts"),
1504                root.join("packages/shared/lib/index.ts"),
1505            ],
1506        );
1507
1508        assert_eq!(
1509            search_roots,
1510            vec![
1511                root.clone(),
1512                root.join("apps"),
1513                root.join("apps/query"),
1514                root.join("apps/query/src"),
1515                root.join("packages"),
1516                root.join("packages/shared"),
1517                root.join("packages/shared/lib"),
1518            ]
1519        );
1520    }
1521
1522    #[test]
1523    fn bucket_files_by_workspace_uses_workspace_relative_paths() {
1524        let root = PathBuf::from("/repo");
1525        let ui = fallow_config::WorkspaceInfo {
1526            root: root.join("apps/ui"),
1527            name: "ui".to_string(),
1528            is_internal_dependency: false,
1529        };
1530        let api = fallow_config::WorkspaceInfo {
1531            root: root.join("apps/api"),
1532            name: "api".to_string(),
1533            is_internal_dependency: false,
1534        };
1535        let workspace_pkgs = vec![
1536            (
1537                &ui,
1538                fallow_config::PackageJson {
1539                    name: Some("ui".to_string()),
1540                    ..Default::default()
1541                },
1542            ),
1543            (
1544                &api,
1545                fallow_config::PackageJson {
1546                    name: Some("api".to_string()),
1547                    ..Default::default()
1548                },
1549            ),
1550        ];
1551        let files = vec![
1552            root.join("apps/ui/vite.config.ts"),
1553            root.join("apps/ui/src/main.ts"),
1554            root.join("apps/api/src/server.ts"),
1555            root.join("tools/build.ts"),
1556        ];
1557
1558        let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
1559
1560        assert_eq!(
1561            buckets[0],
1562            vec![
1563                (
1564                    root.join("apps/ui/vite.config.ts"),
1565                    "vite.config.ts".to_string()
1566                ),
1567                (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
1568            ]
1569        );
1570        assert_eq!(
1571            buckets[1],
1572            vec![(
1573                root.join("apps/api/src/server.ts"),
1574                "src/server.ts".to_string()
1575            )]
1576        );
1577    }
1578
1579    #[test]
1580    fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
1581        let dir = tempfile::tempdir().expect("create temp dir");
1582        let pkg_good = dir.path().join("packages").join("good");
1583        let pkg_bad = dir.path().join("packages").join("bad");
1584        std::fs::create_dir_all(&pkg_good).unwrap();
1585        std::fs::create_dir_all(&pkg_bad).unwrap();
1586        std::fs::write(
1587            dir.path().join("package.json"),
1588            r#"{"workspaces": ["packages/*"]}"#,
1589        )
1590        .unwrap();
1591        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1592        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1593
1594        let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
1595            dir.path(),
1596            &globset::GlobSet::empty(),
1597        )
1598        .expect("root package.json is valid");
1599        assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
1600        fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
1601
1602        warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
1603
1604        let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1605        let mut malformed = 0;
1606        let mut undeclared_for_bad = 0;
1607        for diag in &diagnostics {
1608            if matches!(
1609                diag.kind,
1610                WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1611            ) && diag.path.ends_with("bad")
1612            {
1613                malformed += 1;
1614            }
1615            if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
1616                && diag.path.ends_with("bad")
1617            {
1618                undeclared_for_bad += 1;
1619            }
1620        }
1621        assert_eq!(
1622            malformed, 1,
1623            "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
1624        );
1625        assert_eq!(
1626            undeclared_for_bad, 0,
1627            "warn_undeclared_workspaces must NOT re-flag a path that already \
1628             carries MalformedPackageJson; got duplicates: {diagnostics:?}"
1629        );
1630    }
1631}