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