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
922    let mut nm_roots: Vec<&std::path::Path> = Vec::new();
923    if config.root.join("node_modules").is_dir() {
924        nm_roots.push(&config.root);
925    }
926    for ws in workspaces {
927        if ws.root.join("node_modules").is_dir() {
928            nm_roots.push(&ws.root);
929        }
930    }
931    let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
932
933    if let Some(pkg) = root_pkg
934        && let Some(ref pkg_scripts) = pkg.scripts
935    {
936        let scripts_to_analyze = if config.production {
937            scripts::filter_production_scripts(pkg_scripts)
938        } else {
939            pkg_scripts.clone()
940        };
941        let script_analysis = scripts::analyze_scripts(&scripts_to_analyze, &config.root, &bin_map);
942        plugin_result.script_used_packages = script_analysis.used_packages;
943
944        for config_file in &script_analysis.config_files {
945            plugin_result
946                .discovered_always_used
947                .push((config_file.clone(), "scripts".to_string()));
948        }
949        for entry in &script_analysis.entry_files {
950            if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
951                plugin_result
952                    .entry_patterns
953                    .push((plugins::PathRule::new(pat), "scripts".to_string()));
954            }
955        }
956    }
957    use rayon::prelude::*;
958    type WsScriptOut = (
959        Vec<String>,
960        Vec<(String, String)>,
961        Vec<(plugins::PathRule, String)>,
962    );
963    let ws_results: Vec<WsScriptOut> = workspace_pkgs
964        .par_iter()
965        .map(|(ws, ws_pkg)| {
966            let mut used_packages = Vec::new();
967            let mut discovered_always_used: Vec<(String, String)> = Vec::new();
968            let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
969            if let Some(ref ws_scripts) = ws_pkg.scripts {
970                let scripts_to_analyze = if config.production {
971                    scripts::filter_production_scripts(ws_scripts)
972                } else {
973                    ws_scripts.clone()
974                };
975                let ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root, &bin_map);
976                used_packages.extend(ws_analysis.used_packages);
977
978                let ws_prefix = ws
979                    .root
980                    .strip_prefix(&config.root)
981                    .unwrap_or(&ws.root)
982                    .to_string_lossy();
983                for config_file in &ws_analysis.config_files {
984                    discovered_always_used
985                        .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
986                }
987                for entry in &ws_analysis.entry_files {
988                    if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
989                        entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
990                    }
991                }
992            }
993            (used_packages, discovered_always_used, entry_patterns)
994        })
995        .collect();
996    for (used_packages, discovered_always_used, entry_patterns) in ws_results {
997        plugin_result.script_used_packages.extend(used_packages);
998        plugin_result
999            .discovered_always_used
1000            .extend(discovered_always_used);
1001        plugin_result.entry_patterns.extend(entry_patterns);
1002    }
1003
1004    let ci_analysis = scripts::ci::analyze_ci_files(&config.root, &bin_map);
1005    plugin_result
1006        .script_used_packages
1007        .extend(ci_analysis.used_packages);
1008    for entry in &ci_analysis.entry_files {
1009        if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1010            plugin_result
1011                .entry_patterns
1012                .push((plugins::PathRule::new(pat), "scripts".to_string()));
1013        }
1014    }
1015    plugin_result
1016        .entry_point_roles
1017        .entry("scripts".to_string())
1018        .or_insert(EntryPointRole::Support);
1019}
1020
1021/// Discover all entry points from static patterns, workspaces, plugins, and infrastructure.
1022fn discover_all_entry_points(
1023    config: &ResolvedConfig,
1024    files: &[discover::DiscoveredFile],
1025    workspaces: &[fallow_config::WorkspaceInfo],
1026    root_pkg: Option<&PackageJson>,
1027    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1028    plugin_result: &plugins::AggregatedPluginResult,
1029) -> discover::CategorizedEntryPoints {
1030    let mut entry_points = discover::CategorizedEntryPoints::default();
1031    let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1032        config,
1033        files,
1034        root_pkg,
1035        workspaces.is_empty(),
1036    );
1037
1038    let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1039        workspace_pkgs
1040            .iter()
1041            .map(|(ws, pkg)| (ws.root.clone(), pkg))
1042            .collect();
1043
1044    let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1045        .par_iter()
1046        .map(|ws| {
1047            let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1048            discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1049        })
1050        .collect();
1051    let mut skipped_entries = rustc_hash::FxHashMap::default();
1052    entry_points.extend_runtime(root_discovery.entries);
1053    for (path, count) in root_discovery.skipped_entries {
1054        *skipped_entries.entry(path).or_insert(0) += count;
1055    }
1056    let mut ws_entries = Vec::new();
1057    for workspace in workspace_discovery {
1058        ws_entries.extend(workspace.entries);
1059        for (path, count) in workspace.skipped_entries {
1060            *skipped_entries.entry(path).or_insert(0) += count;
1061        }
1062    }
1063    discover::warn_skipped_entry_summary(&skipped_entries);
1064    entry_points.extend_runtime(ws_entries);
1065
1066    let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1067    entry_points.extend(plugin_entries);
1068
1069    let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1070    entry_points.extend_runtime(infra_entries);
1071
1072    if !config.dynamically_loaded.is_empty() {
1073        let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1074        entry_points.extend_runtime(dynamic_entries);
1075    }
1076
1077    entry_points.dedup()
1078}
1079
1080/// Summarize entry points by source category for user-facing output.
1081fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1082    let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1083    for ep in entry_points {
1084        let category = match &ep.source {
1085            discover::EntryPointSource::PackageJsonMain
1086            | discover::EntryPointSource::PackageJsonModule
1087            | discover::EntryPointSource::PackageJsonExports
1088            | discover::EntryPointSource::PackageJsonBin
1089            | discover::EntryPointSource::PackageJsonScript => "package.json",
1090            discover::EntryPointSource::Plugin { .. } => "plugin",
1091            discover::EntryPointSource::TestFile => "test file",
1092            discover::EntryPointSource::DefaultIndex => "default index",
1093            discover::EntryPointSource::ManualEntry => "manual entry",
1094            discover::EntryPointSource::InfrastructureConfig => "config",
1095            discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1096        };
1097        *counts.entry(category.to_string()).or_insert(0) += 1;
1098    }
1099    let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1100    by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1101    results::EntryPointSummary {
1102        total: entry_points.len(),
1103        by_source,
1104    }
1105}
1106
1107fn append_package_file_asset_patterns(
1108    result: &mut plugins::AggregatedPluginResult,
1109    prefix: &str,
1110    pkg: &PackageJson,
1111) {
1112    let prefix = prefix.trim_matches('/');
1113    for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1114        let pattern = if prefix.is_empty() {
1115            pattern
1116        } else {
1117            format!("{prefix}/{pattern}")
1118        };
1119        result
1120            .discovered_always_used
1121            .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1122    }
1123}
1124
1125fn append_workspace_package_file_asset_patterns(
1126    result: &mut plugins::AggregatedPluginResult,
1127    config: &ResolvedConfig,
1128    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1129) {
1130    for (ws, ws_pkg) in workspace_pkgs {
1131        let ws_prefix = ws
1132            .root
1133            .strip_prefix(&config.root)
1134            .unwrap_or(&ws.root)
1135            .to_string_lossy()
1136            .replace('\\', "/");
1137        append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1138    }
1139}
1140
1141/// Run plugins for root project and all workspace packages.
1142fn run_plugins(
1143    config: &ResolvedConfig,
1144    files: &[discover::DiscoveredFile],
1145    workspaces: &[fallow_config::WorkspaceInfo],
1146    root_pkg: Option<&PackageJson>,
1147    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1148) -> plugins::AggregatedPluginResult {
1149    let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1150    let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1151    let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1152    let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1153        .iter()
1154        .map(std::path::PathBuf::as_path)
1155        .collect();
1156
1157    let mut result = root_pkg.map_or_else(plugins::AggregatedPluginResult::default, |pkg| {
1158        registry.run_with_search_roots(
1159            pkg,
1160            &config.root,
1161            &file_paths,
1162            &root_config_search_root_refs,
1163            config.production,
1164        )
1165    });
1166    if let Some(pkg) = root_pkg {
1167        append_package_file_asset_patterns(&mut result, "", pkg);
1168    }
1169
1170    if workspaces.is_empty() {
1171        gate_auto_import_entry_patterns(&mut result, config, workspaces);
1172        return result;
1173    }
1174
1175    append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1176
1177    let root_active_plugins: rustc_hash::FxHashSet<&str> =
1178        result.active_plugins.iter().map(String::as_str).collect();
1179
1180    let precompiled_matchers = registry.precompile_config_matchers();
1181    let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1182
1183    let ws_results: Vec<_> = workspace_pkgs
1184        .par_iter()
1185        .zip(workspace_relative_files.par_iter())
1186        .filter_map(|((ws, ws_pkg), relative_files)| {
1187            let ws_result = registry.run_workspace_fast(
1188                ws_pkg,
1189                &ws.root,
1190                &config.root,
1191                &precompiled_matchers,
1192                relative_files,
1193                &root_active_plugins,
1194                config.production,
1195            );
1196            if ws_result.active_plugins.is_empty() {
1197                return None;
1198            }
1199            let ws_prefix = ws
1200                .root
1201                .strip_prefix(&config.root)
1202                .unwrap_or(&ws.root)
1203                .to_string_lossy()
1204                .into_owned();
1205            Some((ws_result, ws_prefix))
1206        })
1207        .collect();
1208
1209    for (mut ws_result, ws_prefix) in ws_results {
1210        ws_result.apply_workspace_prefix(&ws_prefix);
1211        ws_result.config_patterns.clear();
1212        ws_result.script_used_packages.clear();
1213        result.merge_into(ws_result);
1214    }
1215
1216    gate_auto_import_entry_patterns(&mut result, config, workspaces);
1217
1218    result
1219}
1220
1221/// When `autoImports` is enabled, drop the modeled Nuxt convention entry
1222/// patterns so genuinely-unreferenced convention files are reported as
1223/// `unused-file`. Component and script fallbacks have separate conservative
1224/// config guards because custom `components:` and `imports:` settings affect
1225/// different convention surfaces.
1226fn gate_auto_import_entry_patterns(
1227    result: &mut plugins::AggregatedPluginResult,
1228    config: &ResolvedConfig,
1229    workspaces: &[fallow_config::WorkspaceInfo],
1230) {
1231    if !config.auto_imports {
1232        return;
1233    }
1234    if !result.active_plugins.iter().any(|name| name == "nuxt") {
1235        return;
1236    }
1237    let components_custom = plugins::nuxt::config_declares_components(&config.root)
1238        || workspaces
1239            .iter()
1240            .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
1241    let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
1242        || workspaces
1243            .iter()
1244            .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
1245    result.entry_patterns.retain(|(rule, plugin)| {
1246        if plugin != "nuxt" {
1247            return true;
1248        }
1249        if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
1250            return false;
1251        }
1252        if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
1253            return false;
1254        }
1255        true
1256    });
1257}
1258
1259fn bucket_files_by_workspace(
1260    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1261    file_paths: &[std::path::PathBuf],
1262) -> Vec<Vec<(std::path::PathBuf, String)>> {
1263    let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1264
1265    for file_path in file_paths {
1266        for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1267            if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1268                buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1269                break;
1270            }
1271        }
1272    }
1273
1274    buckets
1275}
1276
1277fn collect_config_search_roots(
1278    root: &Path,
1279    file_paths: &[std::path::PathBuf],
1280) -> Vec<std::path::PathBuf> {
1281    let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1282    roots.insert(root.to_path_buf());
1283
1284    for file_path in file_paths {
1285        let mut current = file_path.parent();
1286        while let Some(dir) = current {
1287            if !dir.starts_with(root) {
1288                break;
1289            }
1290            roots.insert(dir.to_path_buf());
1291            if dir == root {
1292                break;
1293            }
1294            current = dir.parent();
1295        }
1296    }
1297
1298    let mut roots_vec: Vec<_> = roots.into_iter().collect();
1299    roots_vec.sort();
1300    roots_vec
1301}
1302
1303/// Run analysis on a project directory (with export usages for LSP Code Lens).
1304///
1305/// # Errors
1306///
1307/// Returns an error if config loading, file discovery, parsing, or analysis fails.
1308#[deprecated(
1309    since = "2.76.0",
1310    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."
1311)]
1312pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1313    let config = default_config(root);
1314    #[expect(
1315        deprecated,
1316        reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1317    )]
1318    analyze_with_usages(&config)
1319}
1320
1321/// Resolve the analysis config for a project, mirroring the CLI's `--config`
1322/// behavior when `config_path` is provided.
1323///
1324/// # Errors
1325///
1326/// Returns an error when an explicit config cannot be loaded or automatic
1327/// config discovery finds an invalid config.
1328pub fn config_for_project(
1329    root: &Path,
1330    config_path: Option<&Path>,
1331) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1332    let user_config = if let Some(path) = config_path {
1333        Some((
1334            fallow_config::FallowConfig::load(path)
1335                .map_err(|e| FallowError::config(format!("{e:#}")))?,
1336            path.to_path_buf(),
1337        ))
1338    } else {
1339        fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1340    };
1341
1342    let config = match user_config {
1343        Some((mut config, path)) => {
1344            let dead_code_production = config
1345                .production
1346                .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1347            config.production = dead_code_production.into();
1348            config
1349                .validate_resolved_boundaries(root)
1350                .map_err(|errors| {
1351                    let joined = errors
1352                        .iter()
1353                        .map(ToString::to_string)
1354                        .collect::<Vec<_>>()
1355                        .join("\n  - ");
1356                    FallowError::config(format!("invalid boundary configuration:\n  - {joined}"))
1357                })?;
1358            (
1359                config.resolve(
1360                    root.to_path_buf(),
1361                    fallow_config::OutputFormat::Human,
1362                    num_cpus(),
1363                    false,
1364                    true, // quiet: LSP/programmatic callers don't need progress bars
1365                    None, // LSP/programmatic embedders use the default cache cap
1366                ),
1367                Some(path),
1368            )
1369        }
1370        None => (
1371            fallow_config::FallowConfig::default().resolve(
1372                root.to_path_buf(),
1373                fallow_config::OutputFormat::Human,
1374                num_cpus(),
1375                false,
1376                true,
1377                None,
1378            ),
1379            None,
1380        ),
1381    };
1382
1383    Ok(config)
1384}
1385
1386/// Create a default config for a project root.
1387///
1388/// `analyze_project` is the dead-code entry point used by the LSP and other
1389/// programmatic embedders. When the loaded config uses the per-analysis
1390/// production form (`production: { deadCode: true, ... }`), the production
1391/// flag must be flattened to the dead-code analysis here. Otherwise
1392/// `ResolvedConfig::resolve` calls `.global()` which returns false for the
1393/// per-analysis variant and the production-mode rule overrides
1394/// (`unused_dev_dependencies: off`, etc.) plus `resolved.production = true`
1395/// are silently dropped.
1396pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
1397    config_for_project(root, None).map_or_else(
1398        |_| {
1399            fallow_config::FallowConfig::default().resolve(
1400                root.to_path_buf(),
1401                fallow_config::OutputFormat::Human,
1402                num_cpus(),
1403                false,
1404                true,
1405                None,
1406            )
1407        },
1408        |(config, _)| config,
1409    )
1410}
1411
1412fn num_cpus() -> usize {
1413    std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use super::{
1419        bucket_files_by_workspace, collect_config_search_roots,
1420        format_undeclared_workspace_warning, warn_undeclared_workspaces,
1421    };
1422    use std::path::{Path, PathBuf};
1423
1424    use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
1425
1426    fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
1427        WorkspaceDiagnostic::new(
1428            root,
1429            root.join(relative),
1430            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1431        )
1432    }
1433
1434    #[test]
1435    fn undeclared_workspace_warning_is_singular_for_one_path() {
1436        let root = Path::new("/repo");
1437        let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
1438            .expect("warning should be rendered");
1439
1440        assert_eq!(
1441            warning,
1442            "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."
1443        );
1444    }
1445
1446    #[test]
1447    fn undeclared_workspace_warning_summarizes_many_paths() {
1448        let root = PathBuf::from("/repo");
1449        let diagnostics = [
1450            "examples/a",
1451            "examples/b",
1452            "examples/c",
1453            "examples/d",
1454            "examples/e",
1455            "examples/f",
1456        ]
1457        .into_iter()
1458        .map(|path| diag(&root, path))
1459        .collect::<Vec<_>>();
1460
1461        let warning = format_undeclared_workspace_warning(&root, &diagnostics)
1462            .expect("warning should be rendered");
1463
1464        assert_eq!(
1465            warning,
1466            "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."
1467        );
1468    }
1469
1470    #[test]
1471    fn collect_config_search_roots_includes_file_ancestors_once() {
1472        let root = PathBuf::from("/repo");
1473        let search_roots = collect_config_search_roots(
1474            &root,
1475            &[
1476                root.join("apps/query/src/main.ts"),
1477                root.join("packages/shared/lib/index.ts"),
1478            ],
1479        );
1480
1481        assert_eq!(
1482            search_roots,
1483            vec![
1484                root.clone(),
1485                root.join("apps"),
1486                root.join("apps/query"),
1487                root.join("apps/query/src"),
1488                root.join("packages"),
1489                root.join("packages/shared"),
1490                root.join("packages/shared/lib"),
1491            ]
1492        );
1493    }
1494
1495    #[test]
1496    fn bucket_files_by_workspace_uses_workspace_relative_paths() {
1497        let root = PathBuf::from("/repo");
1498        let ui = fallow_config::WorkspaceInfo {
1499            root: root.join("apps/ui"),
1500            name: "ui".to_string(),
1501            is_internal_dependency: false,
1502        };
1503        let api = fallow_config::WorkspaceInfo {
1504            root: root.join("apps/api"),
1505            name: "api".to_string(),
1506            is_internal_dependency: false,
1507        };
1508        let workspace_pkgs = vec![
1509            (
1510                &ui,
1511                fallow_config::PackageJson {
1512                    name: Some("ui".to_string()),
1513                    ..Default::default()
1514                },
1515            ),
1516            (
1517                &api,
1518                fallow_config::PackageJson {
1519                    name: Some("api".to_string()),
1520                    ..Default::default()
1521                },
1522            ),
1523        ];
1524        let files = vec![
1525            root.join("apps/ui/vite.config.ts"),
1526            root.join("apps/ui/src/main.ts"),
1527            root.join("apps/api/src/server.ts"),
1528            root.join("tools/build.ts"),
1529        ];
1530
1531        let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
1532
1533        assert_eq!(
1534            buckets[0],
1535            vec![
1536                (
1537                    root.join("apps/ui/vite.config.ts"),
1538                    "vite.config.ts".to_string()
1539                ),
1540                (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
1541            ]
1542        );
1543        assert_eq!(
1544            buckets[1],
1545            vec![(
1546                root.join("apps/api/src/server.ts"),
1547                "src/server.ts".to_string()
1548            )]
1549        );
1550    }
1551
1552    #[test]
1553    fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
1554        let dir = tempfile::tempdir().expect("create temp dir");
1555        let pkg_good = dir.path().join("packages").join("good");
1556        let pkg_bad = dir.path().join("packages").join("bad");
1557        std::fs::create_dir_all(&pkg_good).unwrap();
1558        std::fs::create_dir_all(&pkg_bad).unwrap();
1559        std::fs::write(
1560            dir.path().join("package.json"),
1561            r#"{"workspaces": ["packages/*"]}"#,
1562        )
1563        .unwrap();
1564        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1565        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1566
1567        let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
1568            dir.path(),
1569            &globset::GlobSet::empty(),
1570        )
1571        .expect("root package.json is valid");
1572        assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
1573        fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
1574
1575        warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
1576
1577        let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1578        let mut malformed = 0;
1579        let mut undeclared_for_bad = 0;
1580        for diag in &diagnostics {
1581            if matches!(
1582                diag.kind,
1583                WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1584            ) && diag.path.ends_with("bad")
1585            {
1586                malformed += 1;
1587            }
1588            if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
1589                && diag.path.ends_with("bad")
1590            {
1591                undeclared_for_bad += 1;
1592            }
1593        }
1594        assert_eq!(
1595            malformed, 1,
1596            "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
1597        );
1598        assert_eq!(
1599            undeclared_for_bad, 0,
1600            "warn_undeclared_workspaces must NOT re-flag a path that already \
1601             carries MalformedPackageJson; got duplicates: {diagnostics:?}"
1602        );
1603    }
1604}