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