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;
42pub mod trace_chain;
43
44pub use fallow_graph::graph;
45pub use fallow_graph::project;
46pub use fallow_graph::resolve;
47
48use std::path::{Path, PathBuf};
49use std::time::Instant;
50
51use errors::FallowError;
52use fallow_config::{
53    EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces,
54    find_undeclared_workspaces_with_ignores,
55};
56use rayon::prelude::*;
57use results::AnalysisResults;
58use rustc_hash::FxHashSet;
59use trace::PipelineTimings;
60
61const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
62type LoadedWorkspacePackage<'a> = (&'a fallow_config::WorkspaceInfo, PackageJson);
63
64fn record_graph_package_usage(
65    graph: &mut graph::ModuleGraph,
66    package_name: &str,
67    file_id: discover::FileId,
68    is_type_only: bool,
69) {
70    graph
71        .package_usage
72        .entry(package_name.to_owned())
73        .or_default()
74        .push(file_id);
75    if is_type_only {
76        graph
77            .type_only_package_usage
78            .entry(package_name.to_owned())
79            .or_default()
80            .push(file_id);
81    }
82}
83
84fn workspace_package_name<'a>(
85    source: &str,
86    workspace_names: &'a FxHashSet<&str>,
87) -> Option<&'a str> {
88    if !resolve::is_bare_specifier(source) {
89        return None;
90    }
91    let package_name = resolve::extract_package_name(source);
92    workspace_names.get(package_name.as_str()).copied()
93}
94
95fn credit_workspace_package_usage(
96    graph: &mut graph::ModuleGraph,
97    resolved: &[resolve::ResolvedModule],
98    workspaces: &[fallow_config::WorkspaceInfo],
99) {
100    if workspaces.is_empty() {
101        return;
102    }
103
104    let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
105    for module in resolved {
106        for import in module.all_resolved_imports() {
107            if matches!(import.target, resolve::ResolveResult::InternalModule(_))
108                && let Some(package_name) =
109                    workspace_package_name(&import.info.source, &workspace_names)
110            {
111                record_graph_package_usage(
112                    graph,
113                    package_name,
114                    module.file_id,
115                    import.info.is_type_only,
116                );
117            }
118        }
119
120        for re_export in &module.re_exports {
121            if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
122                && let Some(package_name) =
123                    workspace_package_name(&re_export.info.source, &workspace_names)
124            {
125                record_graph_package_usage(
126                    graph,
127                    package_name,
128                    module.file_id,
129                    re_export.info.is_type_only,
130                );
131            }
132        }
133    }
134}
135
136fn credit_package_path_references(graph: &mut graph::ModuleGraph, modules: &[extract::ModuleInfo]) {
137    for module in modules {
138        for package_name in &module.package_path_references {
139            record_graph_package_usage(graph, package_name, module.file_id, false);
140        }
141    }
142}
143
144/// Result of the full analysis pipeline, including optional performance timings.
145pub struct AnalysisOutput {
146    pub results: AnalysisResults,
147    pub timings: Option<PipelineTimings>,
148    pub graph: Option<graph::ModuleGraph>,
149    /// Parsed modules from the pipeline, available when `retain_modules` is true.
150    /// Used by combined and LSP flows to share downstream module data.
151    /// Graph-only extraction payloads are released after graph construction.
152    pub modules: Option<Vec<extract::ModuleInfo>>,
153    /// Discovered files from the pipeline, available when `retain_modules` is true.
154    pub files: Option<Vec<discover::DiscoveredFile>>,
155    /// Package names invoked from package.json scripts and CI configs, mirroring
156    /// what the unused-deps detector consults. Populated for every pipeline run;
157    /// trace tooling reads it so `trace_dependency` agrees with `unused-deps` on
158    /// "used vs unused" instead of returning false-negatives for script-only deps.
159    pub script_used_packages: rustc_hash::FxHashSet<String>,
160    /// xxh3 content hash of every parsed source file, keyed by absolute path.
161    /// Used by `fallow fix` to detect on-disk drift between the in-process
162    /// analysis read and the per-file write; if the file's current hash
163    /// differs from the captured value, the fix for that file is skipped
164    /// with a clear diagnostic and exit 2. The hash is the same value
165    /// extract/cache uses for cache invalidation, so a cached parse contributes
166    /// the same hash as a fresh parse. Roughly 8 bytes per file (negligible
167    /// memory cost even on 100k-file projects).
168    pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
169}
170
171/// Update cache: write freshly parsed modules and refresh stale mtime/size entries.
172fn update_cache(
173    store: &mut cache::CacheStore,
174    modules: &[extract::ModuleInfo],
175    files: &[discover::DiscoveredFile],
176) {
177    for module in modules {
178        if let Some(file) = files.get(module.file_id.0 as usize) {
179            let (mt, sz) = file_mtime_and_size(&file.path);
180            if let Some(cached) = store.get_by_path_only(&file.path)
181                && cached.content_hash == module.content_hash
182            {
183                if cached.mtime_secs != mt || cached.file_size != sz {
184                    let preserved_last_access = cached.last_access_secs;
185                    let mut refreshed = cache::module_to_cached(module, mt, sz);
186                    refreshed.last_access_secs = preserved_last_access;
187                    store.insert(&file.path, refreshed);
188                }
189                continue;
190            }
191            store.insert(&file.path, cache::module_to_cached(module, mt, sz));
192        }
193    }
194    store.retain_paths(files);
195}
196
197/// Resolve `config.cache_max_size_mb` into bytes, falling back to the
198/// extract crate's `DEFAULT_CACHE_MAX_SIZE`. Lives at this layer (not on
199/// `ResolvedConfig`) because `fallow-config` does not depend on
200/// `fallow-extract`; the bytes conversion is owned by the cache callsite.
201/// Public so CLI subcommands that load the cache directly (`flags`,
202/// `health`, `coverage analyze`) can call it without re-deriving the
203/// same fallback policy.
204#[must_use]
205pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
206    config
207        .cache_max_size_mb
208        .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
209            (mb as usize).saturating_mul(1024 * 1024)
210        })
211}
212
213/// Extract mtime (seconds since epoch) and file size from a path.
214fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
215    std::fs::metadata(path).map_or((0, 0), |m| {
216        let mt = m
217            .modified()
218            .ok()
219            .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
220            .map_or(0, |d| d.as_secs());
221        (mt, m.len())
222    })
223}
224
225fn format_undeclared_workspace_warning(
226    root: &Path,
227    undeclared: &[fallow_config::WorkspaceDiagnostic],
228) -> Option<String> {
229    if undeclared.is_empty() {
230        return None;
231    }
232
233    let preview = undeclared
234        .iter()
235        .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
236        .map(|diag| {
237            diag.path
238                .strip_prefix(root)
239                .unwrap_or(&diag.path)
240                .display()
241                .to_string()
242                .replace('\\', "/")
243        })
244        .collect::<Vec<_>>();
245    let remaining = undeclared
246        .len()
247        .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
248    let tail = if remaining > 0 {
249        format!(" (and {remaining} more)")
250    } else {
251        String::new()
252    };
253    let noun = if undeclared.len() == 1 {
254        "directory with package.json is"
255    } else {
256        "directories with package.json are"
257    };
258    let guidance = if undeclared.len() == 1 {
259        "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
260    } else {
261        "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
262    };
263
264    Some(format!(
265        "{} {} not declared as {}: {}{}. {}",
266        undeclared.len(),
267        noun,
268        if undeclared.len() == 1 {
269            "a workspace"
270        } else {
271            "workspaces"
272        },
273        preview.join(", "),
274        tail,
275        guidance
276    ))
277}
278
279fn warn_undeclared_workspaces(
280    root: &Path,
281    workspaces_vec: &[fallow_config::WorkspaceInfo],
282    ignore_patterns: &globset::GlobSet,
283    quiet: bool,
284) {
285    let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
286    if undeclared.is_empty() {
287        return;
288    }
289
290    let existing = fallow_config::workspace_diagnostics_for(root);
291    let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
292        .iter()
293        .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
294        .collect();
295    let undeclared: Vec<_> = undeclared
296        .into_iter()
297        .filter(|diag| {
298            let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
299            !already_flagged.contains(&canonical)
300        })
301        .collect();
302    if undeclared.is_empty() {
303        return;
304    }
305
306    fallow_config::append_workspace_diagnostics(root, undeclared.clone());
307
308    if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
309        tracing::warn!("{message}");
310    }
311}
312
313/// Run the full analysis pipeline.
314///
315/// # Errors
316///
317/// Returns an error if file discovery, parsing, or analysis fails.
318#[deprecated(
319    since = "2.76.0",
320    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."
321)]
322pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
323    let output = analyze_full(config, false, false, false, false)?;
324    Ok(output.results)
325}
326
327/// Run the full analysis pipeline with export usage collection (for LSP Code Lens).
328///
329/// # Errors
330///
331/// Returns an error if file discovery, parsing, or analysis fails.
332#[deprecated(
333    since = "2.76.0",
334    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."
335)]
336pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
337    let output = analyze_full(config, false, true, false, false)?;
338    Ok(output.results)
339}
340
341/// Run the full analysis pipeline with export usage collection and retained
342/// per-function complexity modules.
343///
344/// Used by the LSP when opt-in inline complexity code lenses are enabled so
345/// the editor keeps existing export reference lenses while also reading
346/// complexity data from the same parse.
347///
348/// # Errors
349///
350/// Returns an error if file discovery, parsing, or analysis fails.
351#[deprecated(
352    since = "2.90.0",
353    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code and `compute_complexity` instead. NOTE: this combined LSP-only typed surface is not exposed externally. See docs/fallow-core-migration.md and ADR-008."
354)]
355pub fn analyze_with_usages_and_complexity(
356    config: &ResolvedConfig,
357) -> Result<AnalysisOutput, FallowError> {
358    analyze_full(config, false, true, true, true)
359}
360
361/// Run the full analysis pipeline with optional performance timings and graph retention.
362///
363/// # Errors
364///
365/// Returns an error if file discovery, parsing, or analysis fails.
366#[deprecated(
367    since = "2.76.0",
368    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 dead-code --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
369)]
370pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
371    analyze_full(config, true, false, false, false)
372}
373
374/// Run the full analysis pipeline and return the full `AnalysisOutput`, including
375/// `file_hashes` (used by `fallow fix` to detect on-disk drift between analysis
376/// and per-file write). Graphs and modules are NOT retained; the only difference
377/// from `analyze` is that the caller can access `AnalysisOutput.file_hashes`.
378///
379/// # Errors
380///
381/// Returns an error if file discovery, parsing, or analysis fails.
382#[deprecated(
383    since = "2.76.0",
384    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."
385)]
386pub fn analyze_with_file_hashes(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
387    analyze_full(config, false, false, false, false)
388}
389
390/// Run the full analysis pipeline, retaining parsed modules and discovered files.
391///
392/// Used by the combined command to share a single parse across dead-code and health.
393/// When `need_complexity` is true, the `ComplexityVisitor` runs during parsing so
394/// the returned modules contain per-function complexity data.
395///
396/// # Errors
397///
398/// Returns an error if file discovery, parsing, or analysis fails.
399#[deprecated(
400    since = "2.76.0",
401    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."
402)]
403pub fn analyze_retaining_modules(
404    config: &ResolvedConfig,
405    need_complexity: bool,
406    retain_graph: bool,
407) -> Result<AnalysisOutput, FallowError> {
408    analyze_full(config, retain_graph, false, need_complexity, true)
409}
410
411fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
412    let show_progress = !config.quiet
413        && std::io::IsTerminal::is_terminal(&std::io::stderr())
414        && matches!(
415            config.output,
416            fallow_config::OutputFormat::Human
417                | fallow_config::OutputFormat::Compact
418                | fallow_config::OutputFormat::Markdown
419        );
420    progress::AnalysisProgress::new(show_progress)
421}
422
423fn warn_missing_node_modules(config: &ResolvedConfig) {
424    if config.root.join("node_modules").is_dir() {
425        return;
426    }
427
428    tracing::warn!(
429        "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
430    );
431}
432
433fn discover_analysis_workspaces(
434    config: &ResolvedConfig,
435) -> (Vec<fallow_config::WorkspaceInfo>, f64) {
436    let t = Instant::now();
437    let workspaces = discover_workspaces(&config.root);
438    let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
439    if !workspaces.is_empty() {
440        tracing::info!(count = workspaces.len(), "workspaces discovered");
441    }
442
443    warn_undeclared_workspaces(
444        &config.root,
445        &workspaces,
446        &config.ignore_patterns,
447        config.quiet,
448    );
449
450    (workspaces, workspaces_ms)
451}
452
453/// Owned products of the shared pipeline prelude: progress reporter, project
454/// state (owns discovered files and workspaces), root package.json, and the
455/// discovery/workspace timings.
456struct AnalysisSetup {
457    progress: progress::AnalysisProgress,
458    project: project::ProjectState,
459    root_pkg: Option<PackageJson>,
460    /// Non-source config-candidate files captured by the same discovery walk,
461    /// used to resolve plugin config patterns in-memory (empty in production
462    /// mode, where the filesystem path is kept). Carried alongside `project`
463    /// rather than inside it to avoid churning `ProjectState`'s many callers.
464    config_candidates: Vec<std::path::PathBuf>,
465    discover_ms: f64,
466    workspaces_ms: f64,
467}
468
469/// Run the shared prelude: progress setup, node_modules check, workspace and
470/// root-package discovery, hidden-dir scoping, and file discovery.
471fn run_analysis_setup(config: &ResolvedConfig) -> AnalysisSetup {
472    let progress = new_analysis_progress(config);
473    warn_missing_node_modules(config);
474
475    let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config);
476    let root_pkg = load_root_package_json(config);
477    let discovery_hidden_dir_scopes =
478        discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
479
480    let t = Instant::now();
481    progress.set_stage("discovering files...");
482    let (discovered_files, config_candidates) =
483        discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
484    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
485
486    let project = project::ProjectState::new(discovered_files, workspaces_vec);
487
488    AnalysisSetup {
489        progress,
490        project,
491        root_pkg,
492        config_candidates,
493        discover_ms,
494        workspaces_ms,
495    }
496}
497
498/// Borrowed inputs for plugin detection and script analysis.
499struct PluginScriptInput<'a> {
500    config: &'a ResolvedConfig,
501    progress: &'a progress::AnalysisProgress,
502    files: &'a [discover::DiscoveredFile],
503    workspaces: &'a [fallow_config::WorkspaceInfo],
504    root_pkg: Option<&'a PackageJson>,
505    workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
506    config_candidates: &'a [std::path::PathBuf],
507}
508
509/// Run plugin detection and package.json/CI script analysis, returning the
510/// aggregated plugin result plus the two phase timings.
511fn run_plugins_and_scripts(
512    input: &PluginScriptInput<'_>,
513) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
514    let t = Instant::now();
515    input.progress.set_stage("detecting plugins...");
516    let mut plugin_result = run_plugins(
517        input.config,
518        input.files,
519        input.workspaces,
520        input.root_pkg,
521        input.workspace_pkgs,
522        input.config_candidates,
523    )?;
524    let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
525
526    let t = Instant::now();
527    analyze_all_scripts(
528        input.config,
529        input.workspaces,
530        input.root_pkg,
531        input.workspace_pkgs,
532        &mut plugin_result,
533    );
534    let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
535
536    Ok((plugin_result, plugins_ms, scripts_ms))
537}
538
539/// Run the analysis pipeline using pre-parsed modules, skipping the parsing stage.
540///
541/// This avoids re-parsing files when the caller already has a `ParseResult` (e.g., from
542/// `fallow_core::extract::parse_all_files`). Discovery, plugins, scripts, entry points,
543/// import resolution, graph construction, and dead code detection still run normally.
544/// The graph is always retained (needed for file scores). Caller-owned modules
545/// are borrowed and are not compacted by this API.
546///
547/// # Errors
548///
549/// Returns an error if discovery, graph construction, or analysis fails.
550#[deprecated(
551    since = "2.76.0",
552    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."
553)]
554pub fn analyze_with_parse_result(
555    config: &ResolvedConfig,
556    modules: &[extract::ModuleInfo],
557) -> Result<AnalysisOutput, FallowError> {
558    let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
559    let pipeline_start = Instant::now();
560
561    let AnalysisSetup {
562        progress,
563        project,
564        root_pkg,
565        config_candidates,
566        discover_ms,
567        workspaces_ms,
568    } = run_analysis_setup(config);
569    let files = project.files();
570    let workspaces = project.workspaces();
571    let workspace_pkgs = load_workspace_packages(workspaces);
572
573    let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
574        config,
575        progress: &progress,
576        files,
577        workspaces,
578        root_pkg: root_pkg.as_ref(),
579        workspace_pkgs: &workspace_pkgs,
580        config_candidates: &config_candidates,
581    })?;
582
583    let core = run_reused_analysis_core(&ReusedAnalysisCoreInput {
584        config,
585        progress: &progress,
586        files,
587        workspaces,
588        root_pkg: root_pkg.as_ref(),
589        workspace_pkgs: &workspace_pkgs,
590        plugin_result: &plugin_result,
591        modules,
592    });
593    progress.finish();
594
595    let timings = PreludeTimings {
596        discover_ms,
597        workspaces_ms,
598        plugins_ms,
599        scripts_ms,
600    };
601    let prelude = prelude_metrics(&timings, pipeline_start, files, workspaces, modules.len());
602    let profile = reused_pipeline_profile(&prelude, &core);
603    trace_reused_pipeline_profile(&profile);
604
605    Ok(AnalysisOutput {
606        results: core.result,
607        timings: retained_pipeline_timings(true, &profile),
608        graph: Some(core.graph),
609        modules: None,
610        files: None,
611        script_used_packages: plugin_result.script_used_packages,
612        file_hashes: collect_file_hashes(modules, files),
613    })
614}
615
616/// Prelude/aggregate metrics shared between the parse and reuse pipeline paths
617/// when assembling the `PipelineProfile`.
618struct PreludeMetrics {
619    discover_ms: f64,
620    workspaces_ms: f64,
621    plugins_ms: f64,
622    scripts_ms: f64,
623    total_ms: f64,
624    file_count: usize,
625    workspace_count: usize,
626    module_count: usize,
627}
628
629/// The four prelude phase timings (discovery through script analysis).
630#[expect(
631    clippy::struct_field_names,
632    reason = "timings are all milliseconds; the _ms suffix is the unit"
633)]
634struct PreludeTimings {
635    discover_ms: f64,
636    workspaces_ms: f64,
637    plugins_ms: f64,
638    scripts_ms: f64,
639}
640
641/// Build `PreludeMetrics` from the prelude timings, pipeline start instant, and
642/// the discovered file/workspace/module counts.
643fn prelude_metrics(
644    timings: &PreludeTimings,
645    pipeline_start: Instant,
646    files: &[discover::DiscoveredFile],
647    workspaces: &[fallow_config::WorkspaceInfo],
648    module_count: usize,
649) -> PreludeMetrics {
650    PreludeMetrics {
651        discover_ms: timings.discover_ms,
652        workspaces_ms: timings.workspaces_ms,
653        plugins_ms: timings.plugins_ms,
654        scripts_ms: timings.scripts_ms,
655        total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
656        file_count: files.len(),
657        workspace_count: workspaces.len(),
658        module_count,
659    }
660}
661
662/// Assemble the `PipelineProfile` for the reused-module path (parse/cache phases
663/// are skipped, so their timings are zero).
664fn reused_pipeline_profile(prelude: &PreludeMetrics, core: &ReusedAnalysisCore) -> PipelineProfile {
665    PipelineProfile {
666        discover_ms: prelude.discover_ms,
667        workspaces_ms: prelude.workspaces_ms,
668        plugins_ms: prelude.plugins_ms,
669        scripts_ms: prelude.scripts_ms,
670        parse_ms: 0.0,
671        cache_ms: 0.0,
672        entry_points_ms: core.entry_points_ms,
673        resolve_ms: core.resolve_ms,
674        graph_ms: core.graph_ms,
675        analyze_ms: core.analyze_ms,
676        total_ms: prelude.total_ms,
677        file_count: prelude.file_count,
678        workspace_count: prelude.workspace_count,
679        module_count: prelude.module_count,
680        entry_point_count: core.entry_point_count,
681        cache_hits: 0,
682        cache_misses: 0,
683        parse_cpu_ms: 0.0,
684    }
685}
686
687/// Borrowed inputs for `run_reused_analysis_core`.
688struct ReusedAnalysisCoreInput<'a> {
689    config: &'a ResolvedConfig,
690    progress: &'a progress::AnalysisProgress,
691    files: &'a [discover::DiscoveredFile],
692    workspaces: &'a [fallow_config::WorkspaceInfo],
693    root_pkg: Option<&'a PackageJson>,
694    workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
695    plugin_result: &'a plugins::AggregatedPluginResult,
696    modules: &'a [extract::ModuleInfo],
697}
698
699/// Result of the reused-module analysis core, with the per-phase timings the
700/// caller folds into the pipeline profile.
701struct ReusedAnalysisCore {
702    result: AnalysisResults,
703    graph: graph::ModuleGraph,
704    entry_point_count: usize,
705    entry_points_ms: f64,
706    resolve_ms: f64,
707    graph_ms: f64,
708    analyze_ms: f64,
709}
710
711struct AnalysisCoreSharedInput<'a> {
712    config: &'a ResolvedConfig,
713    progress: &'a progress::AnalysisProgress,
714    files: &'a [discover::DiscoveredFile],
715    workspaces: &'a [fallow_config::WorkspaceInfo],
716    root_pkg: Option<&'a PackageJson>,
717    workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
718    plugin_result: &'a plugins::AggregatedPluginResult,
719}
720
721struct TimedEntryPoints {
722    entry_points: discover::CategorizedEntryPoints,
723    summary: results::EntryPointSummary,
724    count: usize,
725    elapsed_ms: f64,
726}
727
728struct TimedResolvedModules {
729    resolved: Vec<resolve::ResolvedModule>,
730    elapsed_ms: f64,
731}
732
733struct TimedGraph {
734    graph: graph::ModuleGraph,
735    elapsed_ms: f64,
736}
737
738struct TimedAnalysis {
739    result: AnalysisResults,
740    elapsed_ms: f64,
741}
742
743/// Run entry-point discovery, import resolution, graph construction, and dead-code
744/// analysis over caller-owned modules (which are copied before payload release).
745fn run_reused_analysis_core(input: &ReusedAnalysisCoreInput<'_>) -> ReusedAnalysisCore {
746    let &ReusedAnalysisCoreInput {
747        config,
748        progress,
749        files,
750        workspaces,
751        root_pkg,
752        workspace_pkgs,
753        plugin_result,
754        modules,
755    } = input;
756    let shared = AnalysisCoreSharedInput {
757        config,
758        progress,
759        files,
760        workspaces,
761        root_pkg,
762        workspace_pkgs,
763        plugin_result,
764    };
765
766    let entry_points = discover_analysis_entry_points(&shared);
767    let resolved = resolve_analysis_imports_timed(&shared, modules);
768    let graph = build_analysis_graph_timed(&shared, &resolved.resolved, &entry_points, modules);
769
770    let mut analysis_modules = modules.to_vec();
771    release_resolution_payloads(&mut analysis_modules);
772    let analysis = analyze_dead_code_timed(
773        &shared,
774        &graph.graph,
775        &resolved.resolved,
776        &analysis_modules,
777        false,
778        entry_points.summary,
779    );
780
781    ReusedAnalysisCore {
782        result: analysis.result,
783        graph: graph.graph,
784        entry_point_count: entry_points.count,
785        entry_points_ms: entry_points.elapsed_ms,
786        resolve_ms: resolved.elapsed_ms,
787        graph_ms: graph.elapsed_ms,
788        analyze_ms: analysis.elapsed_ms,
789    }
790}
791
792fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
793    let t = Instant::now();
794    let entry_points = discover_all_entry_points(
795        input.config,
796        input.files,
797        input.workspaces,
798        input.root_pkg,
799        input.workspace_pkgs,
800        input.plugin_result,
801    );
802    let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
803    let summary = summarize_entry_points(&entry_points.all);
804    let count = entry_points.all.len();
805
806    TimedEntryPoints {
807        entry_points,
808        summary,
809        count,
810        elapsed_ms,
811    }
812}
813
814fn resolve_analysis_imports_timed(
815    input: &AnalysisCoreSharedInput<'_>,
816    modules: &[extract::ModuleInfo],
817) -> TimedResolvedModules {
818    let t = Instant::now();
819    input.progress.set_stage("resolving imports...");
820    let resolved = resolve_analysis_imports(
821        modules,
822        input.files,
823        input.workspaces,
824        input.plugin_result,
825        input.config,
826    );
827    TimedResolvedModules {
828        resolved,
829        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
830    }
831}
832
833fn build_analysis_graph_timed(
834    input: &AnalysisCoreSharedInput<'_>,
835    resolved: &[resolve::ResolvedModule],
836    entry_points: &TimedEntryPoints,
837    modules: &[extract::ModuleInfo],
838) -> TimedGraph {
839    let t = Instant::now();
840    input.progress.set_stage("building module graph...");
841    let graph = build_analysis_graph(
842        resolved,
843        &entry_points.entry_points,
844        input.files,
845        modules,
846        input.workspaces,
847    );
848    TimedGraph {
849        graph,
850        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
851    }
852}
853
854fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
855    for module in modules {
856        module.release_resolution_payload();
857    }
858}
859
860fn analyze_dead_code_timed(
861    input: &AnalysisCoreSharedInput<'_>,
862    graph: &graph::ModuleGraph,
863    resolved: &[resolve::ResolvedModule],
864    modules: &[extract::ModuleInfo],
865    collect_usages: bool,
866    entry_point_summary: results::EntryPointSummary,
867) -> TimedAnalysis {
868    let t = Instant::now();
869    input.progress.set_stage("analyzing...");
870    #[expect(
871        deprecated,
872        reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
873    )]
874    let mut result = analyze::find_dead_code_full(
875        graph,
876        input.config,
877        resolved,
878        Some(input.plugin_result),
879        input.workspaces,
880        modules,
881        collect_usages,
882    );
883    result.entry_point_summary = Some(entry_point_summary);
884    TimedAnalysis {
885        result,
886        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
887    }
888}
889
890fn analyze_full(
891    config: &ResolvedConfig,
892    retain: bool,
893    collect_usages: bool,
894    need_complexity: bool,
895    retain_modules: bool,
896) -> Result<AnalysisOutput, FallowError> {
897    let _span = tracing::info_span!("fallow_analyze").entered();
898    let pipeline_start = Instant::now();
899
900    let AnalysisSetup {
901        progress,
902        project,
903        root_pkg,
904        config_candidates,
905        discover_ms,
906        workspaces_ms,
907    } = run_analysis_setup(config);
908    let files = project.files();
909    let workspaces = project.workspaces();
910    let workspace_pkgs = load_workspace_packages(workspaces);
911
912    let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
913        config,
914        progress: &progress,
915        files,
916        workspaces,
917        root_pkg: root_pkg.as_ref(),
918        workspace_pkgs: &workspace_pkgs,
919        config_candidates: &config_candidates,
920    })?;
921
922    let FullAnalysisCoreOutput { core, metrics } = run_full_analysis_core(&FullAnalysisCoreInput {
923        config,
924        progress: &progress,
925        files,
926        workspaces,
927        root_pkg: root_pkg.as_ref(),
928        workspace_pkgs: &workspace_pkgs,
929        plugin_result: &plugin_result,
930        need_complexity,
931        collect_usages,
932    });
933    progress.finish();
934
935    let profile = full_analysis_pipeline_profile(
936        &PreludeTimings {
937            discover_ms,
938            workspaces_ms,
939            plugins_ms,
940            scripts_ms,
941        },
942        pipeline_start,
943        files,
944        workspaces,
945        &core,
946        &metrics,
947    );
948    trace_pipeline_profile(&profile);
949
950    Ok(assemble_full_output(
951        core,
952        plugin_result,
953        &profile,
954        files,
955        retain,
956        retain_modules,
957    ))
958}
959
960struct FullAnalysisCoreInput<'a> {
961    config: &'a ResolvedConfig,
962    progress: &'a progress::AnalysisProgress,
963    files: &'a [discover::DiscoveredFile],
964    workspaces: &'a [fallow_config::WorkspaceInfo],
965    root_pkg: Option<&'a PackageJson>,
966    workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
967    plugin_result: &'a plugins::AggregatedPluginResult,
968    need_complexity: bool,
969    collect_usages: bool,
970}
971
972struct FullAnalysisCoreOutput {
973    core: OwnedAnalysisCore,
974    metrics: ParseMetrics,
975}
976
977fn run_full_analysis_core(input: &FullAnalysisCoreInput<'_>) -> FullAnalysisCoreOutput {
978    let &FullAnalysisCoreInput {
979        config,
980        progress,
981        files,
982        workspaces,
983        root_pkg,
984        workspace_pkgs,
985        plugin_result,
986        need_complexity,
987        collect_usages,
988    } = input;
989
990    let t = Instant::now();
991    progress.set_stage(&format!("parsing {} files...", files.len()));
992    let AnalysisParseOutput { modules, metrics } =
993        parse_analysis_modules(config, files, need_complexity, t);
994
995    let core = run_owned_analysis_core(OwnedAnalysisCoreInput {
996        config,
997        progress,
998        files,
999        workspaces,
1000        root_pkg,
1001        workspace_pkgs,
1002        plugin_result,
1003        modules,
1004        collect_usages,
1005    });
1006
1007    FullAnalysisCoreOutput { core, metrics }
1008}
1009
1010fn full_analysis_pipeline_profile(
1011    timings: &PreludeTimings,
1012    pipeline_start: Instant,
1013    files: &[discover::DiscoveredFile],
1014    workspaces: &[fallow_config::WorkspaceInfo],
1015    core: &OwnedAnalysisCore,
1016    metrics: &ParseMetrics,
1017) -> PipelineProfile {
1018    let prelude = prelude_metrics(
1019        timings,
1020        pipeline_start,
1021        files,
1022        workspaces,
1023        core.modules.len(),
1024    );
1025    full_pipeline_profile(&prelude, core, metrics)
1026}
1027
1028/// Assemble the `AnalysisOutput` for the full pipeline, honoring the graph/module
1029/// retention flags and computing per-file content hashes.
1030fn assemble_full_output(
1031    core: OwnedAnalysisCore,
1032    plugin_result: plugins::AggregatedPluginResult,
1033    profile: &PipelineProfile,
1034    files: &[discover::DiscoveredFile],
1035    retain: bool,
1036    retain_modules: bool,
1037) -> AnalysisOutput {
1038    let file_hashes = collect_file_hashes(&core.modules, files);
1039    AnalysisOutput {
1040        results: core.result,
1041        timings: retained_pipeline_timings(retain, profile),
1042        graph: if retain { Some(core.graph) } else { None },
1043        modules: if retain_modules {
1044            Some(core.modules)
1045        } else {
1046            None
1047        },
1048        files: if retain_modules {
1049            Some(files.to_vec())
1050        } else {
1051            None
1052        },
1053        script_used_packages: plugin_result.script_used_packages,
1054        file_hashes,
1055    }
1056}
1057
1058/// Borrowed inputs (plus owned `modules`) for `run_owned_analysis_core`.
1059struct OwnedAnalysisCoreInput<'a> {
1060    config: &'a ResolvedConfig,
1061    progress: &'a progress::AnalysisProgress,
1062    files: &'a [discover::DiscoveredFile],
1063    workspaces: &'a [fallow_config::WorkspaceInfo],
1064    root_pkg: Option<&'a PackageJson>,
1065    workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
1066    plugin_result: &'a plugins::AggregatedPluginResult,
1067    modules: Vec<extract::ModuleInfo>,
1068    collect_usages: bool,
1069}
1070
1071/// Result of the freshly-parsed analysis core; returns the owned `modules` (so the
1072/// caller can retain them) plus the per-phase timings.
1073struct OwnedAnalysisCore {
1074    result: AnalysisResults,
1075    graph: graph::ModuleGraph,
1076    modules: Vec<extract::ModuleInfo>,
1077    entry_point_count: usize,
1078    entry_points_ms: f64,
1079    resolve_ms: f64,
1080    graph_ms: f64,
1081    analyze_ms: f64,
1082}
1083
1084/// Run entry-point discovery, import resolution, graph construction (releasing
1085/// each module's resolution payload in place), and dead-code analysis over the
1086/// freshly parsed, owned modules.
1087fn run_owned_analysis_core(input: OwnedAnalysisCoreInput<'_>) -> OwnedAnalysisCore {
1088    let OwnedAnalysisCoreInput {
1089        config,
1090        progress,
1091        files,
1092        workspaces,
1093        root_pkg,
1094        workspace_pkgs,
1095        plugin_result,
1096        mut modules,
1097        collect_usages,
1098    } = input;
1099    let shared = AnalysisCoreSharedInput {
1100        config,
1101        progress,
1102        files,
1103        workspaces,
1104        root_pkg,
1105        workspace_pkgs,
1106        plugin_result,
1107    };
1108
1109    let entry_points = discover_analysis_entry_points(&shared);
1110    let resolved = resolve_analysis_imports_timed(&shared, &modules);
1111    let graph = build_analysis_graph_timed(&shared, &resolved.resolved, &entry_points, &modules);
1112    release_resolution_payloads(&mut modules);
1113    let analysis = analyze_dead_code_timed(
1114        &shared,
1115        &graph.graph,
1116        &resolved.resolved,
1117        &modules,
1118        collect_usages,
1119        entry_points.summary,
1120    );
1121
1122    OwnedAnalysisCore {
1123        result: analysis.result,
1124        graph: graph.graph,
1125        modules,
1126        entry_point_count: entry_points.count,
1127        entry_points_ms: entry_points.elapsed_ms,
1128        resolve_ms: resolved.elapsed_ms,
1129        graph_ms: graph.elapsed_ms,
1130        analyze_ms: analysis.elapsed_ms,
1131    }
1132}
1133
1134/// Assemble the `PipelineProfile` for the full (freshly parsed) pipeline path.
1135fn full_pipeline_profile(
1136    prelude: &PreludeMetrics,
1137    core: &OwnedAnalysisCore,
1138    parse: &ParseMetrics,
1139) -> PipelineProfile {
1140    PipelineProfile {
1141        discover_ms: prelude.discover_ms,
1142        workspaces_ms: prelude.workspaces_ms,
1143        plugins_ms: prelude.plugins_ms,
1144        scripts_ms: prelude.scripts_ms,
1145        parse_ms: parse.parse_ms,
1146        cache_ms: parse.cache_ms,
1147        entry_points_ms: core.entry_points_ms,
1148        resolve_ms: core.resolve_ms,
1149        graph_ms: core.graph_ms,
1150        analyze_ms: core.analyze_ms,
1151        total_ms: prelude.total_ms,
1152        file_count: prelude.file_count,
1153        workspace_count: prelude.workspace_count,
1154        module_count: prelude.module_count,
1155        entry_point_count: core.entry_point_count,
1156        cache_hits: parse.cache_hits,
1157        cache_misses: parse.cache_misses,
1158        parse_cpu_ms: parse.parse_cpu_ms,
1159    }
1160}
1161
1162#[derive(Clone, Copy)]
1163struct PipelineProfile {
1164    discover_ms: f64,
1165    workspaces_ms: f64,
1166    plugins_ms: f64,
1167    scripts_ms: f64,
1168    parse_ms: f64,
1169    cache_ms: f64,
1170    entry_points_ms: f64,
1171    resolve_ms: f64,
1172    graph_ms: f64,
1173    analyze_ms: f64,
1174    total_ms: f64,
1175    file_count: usize,
1176    workspace_count: usize,
1177    module_count: usize,
1178    entry_point_count: usize,
1179    cache_hits: usize,
1180    cache_misses: usize,
1181    parse_cpu_ms: f64,
1182}
1183
1184struct AnalysisParseOutput {
1185    modules: Vec<extract::ModuleInfo>,
1186    metrics: ParseMetrics,
1187}
1188
1189/// Parse/cache phase metrics carried into the full-pipeline `PipelineProfile`.
1190struct ParseMetrics {
1191    parse_ms: f64,
1192    cache_ms: f64,
1193    cache_hits: usize,
1194    cache_misses: usize,
1195    parse_cpu_ms: f64,
1196}
1197
1198fn parse_analysis_modules(
1199    config: &ResolvedConfig,
1200    files: &[discover::DiscoveredFile],
1201    need_complexity: bool,
1202    start: Instant,
1203) -> AnalysisParseOutput {
1204    let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1205    let mut cache_store = if config.no_cache {
1206        None
1207    } else {
1208        cache::CacheStore::load(
1209            &config.cache_dir,
1210            config.cache_config_hash,
1211            cache_max_size_bytes,
1212        )
1213    };
1214
1215    let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
1216    let modules = parse_result.modules;
1217    let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1218    let cache_ms = update_parse_cache_if_enabled(
1219        config,
1220        &mut cache_store,
1221        &modules,
1222        files,
1223        cache_max_size_bytes,
1224    );
1225
1226    AnalysisParseOutput {
1227        modules,
1228        metrics: ParseMetrics {
1229            parse_ms,
1230            cache_ms,
1231            cache_hits: parse_result.cache_hits,
1232            cache_misses: parse_result.cache_misses,
1233            parse_cpu_ms: parse_result.parse_cpu_ms,
1234        },
1235    }
1236}
1237
1238fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1239    retain.then_some(PipelineTimings {
1240        discover_files_ms: profile.discover_ms,
1241        file_count: profile.file_count,
1242        workspaces_ms: profile.workspaces_ms,
1243        workspace_count: profile.workspace_count,
1244        plugins_ms: profile.plugins_ms,
1245        script_analysis_ms: profile.scripts_ms,
1246        parse_extract_ms: profile.parse_ms,
1247        parse_cpu_ms: profile.parse_cpu_ms,
1248        module_count: profile.module_count,
1249        cache_hits: profile.cache_hits,
1250        cache_misses: profile.cache_misses,
1251        cache_update_ms: profile.cache_ms,
1252        entry_points_ms: profile.entry_points_ms,
1253        entry_point_count: profile.entry_point_count,
1254        resolve_imports_ms: profile.resolve_ms,
1255        build_graph_ms: profile.graph_ms,
1256        analyze_ms: profile.analyze_ms,
1257        duplication_ms: None,
1258        total_ms: profile.total_ms,
1259    })
1260}
1261
1262fn trace_reused_pipeline_profile(profile: &PipelineProfile) {
1263    tracing::debug!(
1264        "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
1265         │  discover files:   {:>8.1}ms  ({} files)\n\
1266         │  workspaces:       {:>8.1}ms\n\
1267         │  plugins:          {:>8.1}ms\n\
1268         │  script analysis:  {:>8.1}ms\n\
1269         │  parse/extract:    SKIPPED (reused {} modules)\n\
1270         │  entry points:     {:>8.1}ms  ({} entries)\n\
1271         │  resolve imports:  {:>8.1}ms\n\
1272         │  build graph:      {:>8.1}ms\n\
1273         │  analyze:          {:>8.1}ms\n\
1274         │  ────────────────────────────────────────────\n\
1275         │  TOTAL:            {:>8.1}ms\n\
1276         └─────────────────────────────────────────────────",
1277        profile.discover_ms,
1278        profile.file_count,
1279        profile.workspaces_ms,
1280        profile.plugins_ms,
1281        profile.scripts_ms,
1282        profile.module_count,
1283        profile.entry_points_ms,
1284        profile.entry_point_count,
1285        profile.resolve_ms,
1286        profile.graph_ms,
1287        profile.analyze_ms,
1288        profile.total_ms,
1289    );
1290}
1291
1292fn update_parse_cache_if_enabled(
1293    config: &ResolvedConfig,
1294    cache_store: &mut Option<cache::CacheStore>,
1295    modules: &[extract::ModuleInfo],
1296    files: &[discover::DiscoveredFile],
1297    cache_max_size_bytes: usize,
1298) -> f64 {
1299    let t = Instant::now();
1300    if !config.no_cache {
1301        let store = cache_store.get_or_insert_with(cache::CacheStore::new);
1302        update_cache(store, modules, files);
1303        if let Err(error) = store.save(
1304            &config.cache_dir,
1305            config.cache_config_hash,
1306            cache_max_size_bytes,
1307        ) {
1308            tracing::warn!("Failed to save cache: {error}");
1309        }
1310    }
1311    t.elapsed().as_secs_f64() * 1000.0
1312}
1313
1314fn resolve_analysis_imports(
1315    modules: &[extract::ModuleInfo],
1316    files: &[discover::DiscoveredFile],
1317    workspaces: &[fallow_config::WorkspaceInfo],
1318    plugin_result: &plugins::AggregatedPluginResult,
1319    config: &ResolvedConfig,
1320) -> Vec<resolve::ResolvedModule> {
1321    let mut resolved = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1322        modules,
1323        files,
1324        workspaces,
1325        active_plugins: &plugin_result.active_plugins,
1326        path_aliases: &plugin_result.path_aliases,
1327        auto_imports: &plugin_result.auto_imports,
1328        scss_include_paths: &plugin_result.scss_include_paths,
1329        static_dir_mappings: &plugin_result.static_dir_mappings,
1330        root: &config.root,
1331        extra_conditions: &config.resolve.conditions,
1332    });
1333    external_style_usage::augment_external_style_package_usage(
1334        &mut resolved,
1335        config,
1336        workspaces,
1337        plugin_result,
1338    );
1339    resolved
1340}
1341
1342fn build_analysis_graph(
1343    resolved: &[resolve::ResolvedModule],
1344    entry_points: &discover::CategorizedEntryPoints,
1345    files: &[discover::DiscoveredFile],
1346    modules: &[extract::ModuleInfo],
1347    workspaces: &[fallow_config::WorkspaceInfo],
1348) -> graph::ModuleGraph {
1349    let mut graph = graph::ModuleGraph::build_with_reachability_roots(
1350        resolved,
1351        &entry_points.all,
1352        &entry_points.runtime,
1353        &entry_points.test,
1354        files,
1355    );
1356    credit_package_path_references(&mut graph, modules);
1357    credit_workspace_package_usage(&mut graph, resolved, workspaces);
1358    graph
1359}
1360
1361fn collect_file_hashes(
1362    modules: &[extract::ModuleInfo],
1363    files: &[discover::DiscoveredFile],
1364) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1365    modules
1366        .iter()
1367        .filter_map(|module| {
1368            files
1369                .get(module.file_id.0 as usize)
1370                .map(|file| (file.path.clone(), module.content_hash))
1371        })
1372        .collect()
1373}
1374
1375fn trace_pipeline_profile(profile: &PipelineProfile) {
1376    let PipelineProfile {
1377        discover_ms,
1378        workspaces_ms,
1379        plugins_ms,
1380        scripts_ms,
1381        parse_ms,
1382        cache_ms,
1383        entry_points_ms,
1384        resolve_ms,
1385        graph_ms,
1386        analyze_ms,
1387        total_ms,
1388        file_count,
1389        module_count,
1390        entry_point_count,
1391        cache_hits,
1392        cache_misses,
1393        ..
1394    } = *profile;
1395    let cache_summary = if cache_hits > 0 {
1396        format!(" ({cache_hits} cached, {cache_misses} parsed)")
1397    } else {
1398        String::new()
1399    };
1400
1401    tracing::debug!(
1402        "\n┌─ Pipeline Profile ─────────────────────────────\n\
1403         │  discover files:   {:>8.1}ms  ({} files)\n\
1404         │  workspaces:       {:>8.1}ms\n\
1405         │  plugins:          {:>8.1}ms\n\
1406         │  script analysis:  {:>8.1}ms\n\
1407         │  parse/extract:    {:>8.1}ms  ({} modules{})\n\
1408         │  cache update:     {:>8.1}ms\n\
1409         │  entry points:     {:>8.1}ms  ({} entries)\n\
1410         │  resolve imports:  {:>8.1}ms\n\
1411         │  build graph:      {:>8.1}ms\n\
1412         │  analyze:          {:>8.1}ms\n\
1413         │  ────────────────────────────────────────────\n\
1414         │  TOTAL:            {:>8.1}ms\n\
1415         └─────────────────────────────────────────────────",
1416        discover_ms,
1417        file_count,
1418        workspaces_ms,
1419        plugins_ms,
1420        scripts_ms,
1421        parse_ms,
1422        module_count,
1423        cache_summary,
1424        cache_ms,
1425        entry_points_ms,
1426        entry_point_count,
1427        resolve_ms,
1428        graph_ms,
1429        analyze_ms,
1430        total_ms,
1431    );
1432}
1433
1434/// Analyze package.json scripts from root and all workspace packages.
1435///
1436/// Populates the plugin result with script-used packages and config file
1437/// entry patterns. Also scans CI config files for binary invocations.
1438fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
1439    PackageJson::load(&config.root.join("package.json")).ok()
1440}
1441
1442fn load_workspace_packages(
1443    workspaces: &[fallow_config::WorkspaceInfo],
1444) -> Vec<LoadedWorkspacePackage<'_>> {
1445    workspaces
1446        .iter()
1447        .filter_map(|ws| {
1448            PackageJson::load(&ws.root.join("package.json"))
1449                .ok()
1450                .map(|pkg| (ws, pkg))
1451        })
1452        .collect()
1453}
1454
1455fn analyze_all_scripts(
1456    config: &ResolvedConfig,
1457    workspaces: &[fallow_config::WorkspaceInfo],
1458    root_pkg: Option<&PackageJson>,
1459    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1460    plugin_result: &mut plugins::AggregatedPluginResult,
1461) {
1462    let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
1463    let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
1464    let all_script_names = collect_all_script_names(root_pkg, workspace_pkgs);
1465
1466    let nm_roots = collect_node_modules_roots(config, workspaces);
1467    let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
1468
1469    analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
1470    analyze_workspace_scripts(
1471        config,
1472        workspace_pkgs,
1473        &bin_map,
1474        &all_dep_set,
1475        plugin_result,
1476    );
1477    analyze_ci_scripts(
1478        config,
1479        &bin_map,
1480        &all_dep_set,
1481        &all_script_names,
1482        plugin_result,
1483    );
1484
1485    plugin_result
1486        .entry_point_roles
1487        .entry("scripts".to_string())
1488        .or_insert(EntryPointRole::Support);
1489}
1490
1491/// Gather sorted, deduped dependency names across the root and workspace packages.
1492fn collect_all_dependency_names(
1493    root_pkg: Option<&PackageJson>,
1494    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1495) -> Vec<String> {
1496    let mut all_dep_names: Vec<String> = Vec::new();
1497    if let Some(pkg) = root_pkg {
1498        all_dep_names.extend(pkg.all_dependency_names());
1499    }
1500    for (_, ws_pkg) in workspace_pkgs {
1501        all_dep_names.extend(ws_pkg.all_dependency_names());
1502    }
1503    all_dep_names.sort_unstable();
1504    all_dep_names.dedup();
1505    all_dep_names
1506}
1507
1508/// Gather the union of script names declared in the root and workspace packages.
1509fn collect_all_script_names(
1510    root_pkg: Option<&PackageJson>,
1511    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1512) -> FxHashSet<String> {
1513    let mut all_script_names: FxHashSet<String> = FxHashSet::default();
1514    if let Some(pkg) = root_pkg
1515        && let Some(ref pkg_scripts) = pkg.scripts
1516    {
1517        all_script_names.extend(pkg_scripts.keys().cloned());
1518    }
1519    for (_, ws_pkg) in workspace_pkgs {
1520        if let Some(ref ws_scripts) = ws_pkg.scripts {
1521            all_script_names.extend(ws_scripts.keys().cloned());
1522        }
1523    }
1524    all_script_names
1525}
1526
1527/// Collect every directory (root and workspaces) that has a local `node_modules`.
1528fn collect_node_modules_roots<'a>(
1529    config: &'a ResolvedConfig,
1530    workspaces: &'a [fallow_config::WorkspaceInfo],
1531) -> Vec<&'a std::path::Path> {
1532    let mut nm_roots: Vec<&std::path::Path> = Vec::new();
1533    if config.root.join("node_modules").is_dir() {
1534        nm_roots.push(&config.root);
1535    }
1536    for ws in workspaces {
1537        if ws.root.join("node_modules").is_dir() {
1538            nm_roots.push(&ws.root);
1539        }
1540    }
1541    nm_roots
1542}
1543
1544/// Analyze the root package.json scripts and fold the results into the plugin result.
1545fn analyze_root_scripts(
1546    config: &ResolvedConfig,
1547    root_pkg: Option<&PackageJson>,
1548    bin_map: &rustc_hash::FxHashMap<String, String>,
1549    all_dep_set: &FxHashSet<String>,
1550    plugin_result: &mut plugins::AggregatedPluginResult,
1551) {
1552    let Some(pkg) = root_pkg else {
1553        return;
1554    };
1555    let Some(ref pkg_scripts) = pkg.scripts else {
1556        return;
1557    };
1558    let scripts_to_analyze = if config.production {
1559        scripts::filter_production_scripts(pkg_scripts)
1560    } else {
1561        pkg_scripts.clone()
1562    };
1563    let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1564    let script_analysis = scripts::analyze_scripts_with_dependency_context(
1565        &scripts_to_analyze,
1566        &config.root,
1567        bin_map,
1568        all_dep_set,
1569        &script_names,
1570    );
1571    plugin_result.script_used_packages = script_analysis.used_packages;
1572
1573    for config_file in &script_analysis.config_files {
1574        plugin_result
1575            .discovered_always_used
1576            .push((config_file.clone(), "scripts".to_string()));
1577    }
1578    for entry in &script_analysis.entry_files {
1579        if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1580            plugin_result
1581                .entry_patterns
1582                .push((plugins::PathRule::new(pat), "scripts".to_string()));
1583        }
1584    }
1585}
1586
1587/// Analyze each workspace package's scripts in parallel and merge the results.
1588type WsScriptOut = (
1589    Vec<String>,
1590    Vec<(String, String)>,
1591    Vec<(plugins::PathRule, String)>,
1592);
1593
1594fn analyze_workspace_scripts(
1595    config: &ResolvedConfig,
1596    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1597    bin_map: &rustc_hash::FxHashMap<String, String>,
1598    all_dep_set: &FxHashSet<String>,
1599    plugin_result: &mut plugins::AggregatedPluginResult,
1600) {
1601    let ws_results: Vec<WsScriptOut> = workspace_pkgs
1602        .par_iter()
1603        .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
1604        .collect();
1605    for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1606        plugin_result.script_used_packages.extend(used_packages);
1607        plugin_result
1608            .discovered_always_used
1609            .extend(discovered_always_used);
1610        plugin_result.entry_patterns.extend(entry_patterns);
1611    }
1612}
1613
1614/// Analyze a single workspace package's scripts, returning its used packages,
1615/// always-used config files, and entry patterns (all workspace-prefixed).
1616fn analyze_one_workspace_scripts(
1617    config: &ResolvedConfig,
1618    ws: &fallow_config::WorkspaceInfo,
1619    ws_pkg: &PackageJson,
1620    bin_map: &rustc_hash::FxHashMap<String, String>,
1621    all_dep_set: &FxHashSet<String>,
1622) -> WsScriptOut {
1623    let mut used_packages = Vec::new();
1624    let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1625    let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1626    let Some(ref ws_scripts) = ws_pkg.scripts else {
1627        return (used_packages, discovered_always_used, entry_patterns);
1628    };
1629    let scripts_to_analyze = if config.production {
1630        scripts::filter_production_scripts(ws_scripts)
1631    } else {
1632        ws_scripts.clone()
1633    };
1634    let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
1635    let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1636        &scripts_to_analyze,
1637        &ws.root,
1638        bin_map,
1639        all_dep_set,
1640        &script_names,
1641    );
1642    used_packages.extend(ws_analysis.used_packages);
1643
1644    let ws_prefix = ws
1645        .root
1646        .strip_prefix(&config.root)
1647        .unwrap_or(&ws.root)
1648        .to_string_lossy();
1649    for config_file in &ws_analysis.config_files {
1650        discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1651    }
1652    for entry in &ws_analysis.entry_files {
1653        if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1654            entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1655        }
1656    }
1657    (used_packages, discovered_always_used, entry_patterns)
1658}
1659
1660/// Analyze CI config files for binary invocations and merge the results.
1661fn analyze_ci_scripts(
1662    config: &ResolvedConfig,
1663    bin_map: &rustc_hash::FxHashMap<String, String>,
1664    all_dep_set: &FxHashSet<String>,
1665    all_script_names: &FxHashSet<String>,
1666    plugin_result: &mut plugins::AggregatedPluginResult,
1667) {
1668    let ci_analysis =
1669        scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_script_names);
1670    plugin_result
1671        .script_used_packages
1672        .extend(ci_analysis.used_packages);
1673    for entry in &ci_analysis.entry_files {
1674        if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1675            plugin_result
1676                .entry_patterns
1677                .push((plugins::PathRule::new(pat), "scripts".to_string()));
1678        }
1679    }
1680}
1681
1682/// Discover all entry points from static patterns, workspaces, plugins, and infrastructure.
1683fn discover_all_entry_points(
1684    config: &ResolvedConfig,
1685    files: &[discover::DiscoveredFile],
1686    workspaces: &[fallow_config::WorkspaceInfo],
1687    root_pkg: Option<&PackageJson>,
1688    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1689    plugin_result: &plugins::AggregatedPluginResult,
1690) -> discover::CategorizedEntryPoints {
1691    let mut entry_points = discover::CategorizedEntryPoints::default();
1692    let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1693        config,
1694        files,
1695        root_pkg,
1696        workspaces.is_empty(),
1697    );
1698
1699    let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1700        workspace_pkgs
1701            .iter()
1702            .map(|(ws, pkg)| (ws.root.clone(), pkg))
1703            .collect();
1704
1705    let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1706        .par_iter()
1707        .map(|ws| {
1708            let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1709            discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1710        })
1711        .collect();
1712    let mut skipped_entries = rustc_hash::FxHashMap::default();
1713    entry_points.extend_runtime(root_discovery.entries);
1714    for (path, count) in root_discovery.skipped_entries {
1715        *skipped_entries.entry(path).or_insert(0) += count;
1716    }
1717    let mut ws_entries = Vec::new();
1718    for workspace in workspace_discovery {
1719        ws_entries.extend(workspace.entries);
1720        for (path, count) in workspace.skipped_entries {
1721            *skipped_entries.entry(path).or_insert(0) += count;
1722        }
1723    }
1724    discover::warn_skipped_entry_summary(&skipped_entries);
1725    entry_points.extend_runtime(ws_entries);
1726
1727    let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1728    entry_points.extend(plugin_entries);
1729
1730    let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1731    entry_points.extend_runtime(infra_entries);
1732
1733    if !config.dynamically_loaded.is_empty() {
1734        let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1735        entry_points.extend_runtime(dynamic_entries);
1736    }
1737
1738    entry_points.dedup()
1739}
1740
1741/// Summarize entry points by source category for user-facing output.
1742fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1743    let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1744    for ep in entry_points {
1745        let category = match &ep.source {
1746            discover::EntryPointSource::PackageJsonMain
1747            | discover::EntryPointSource::PackageJsonModule
1748            | discover::EntryPointSource::PackageJsonExports
1749            | discover::EntryPointSource::PackageJsonBin
1750            | discover::EntryPointSource::PackageJsonScript => "package.json",
1751            discover::EntryPointSource::Plugin { .. } => "plugin",
1752            discover::EntryPointSource::TestFile => "test file",
1753            discover::EntryPointSource::DefaultIndex => "default index",
1754            discover::EntryPointSource::ManualEntry => "manual entry",
1755            discover::EntryPointSource::InfrastructureConfig => "config",
1756            discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1757        };
1758        *counts.entry(category.to_string()).or_insert(0) += 1;
1759    }
1760    let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1761    by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1762    results::EntryPointSummary {
1763        total: entry_points.len(),
1764        by_source,
1765    }
1766}
1767
1768fn append_package_file_asset_patterns(
1769    result: &mut plugins::AggregatedPluginResult,
1770    prefix: &str,
1771    pkg: &PackageJson,
1772) {
1773    let prefix = prefix.trim_matches('/');
1774    for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1775        let pattern = if prefix.is_empty() {
1776            pattern
1777        } else {
1778            format!("{prefix}/{pattern}")
1779        };
1780        result
1781            .discovered_always_used
1782            .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1783    }
1784}
1785
1786fn append_workspace_package_file_asset_patterns(
1787    result: &mut plugins::AggregatedPluginResult,
1788    config: &ResolvedConfig,
1789    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1790) {
1791    for (ws, ws_pkg) in workspace_pkgs {
1792        let ws_prefix = ws
1793            .root
1794            .strip_prefix(&config.root)
1795            .unwrap_or(&ws.root)
1796            .to_string_lossy()
1797            .replace('\\', "/");
1798        append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1799    }
1800}
1801
1802/// Run plugins for root project and all workspace packages.
1803fn run_plugins(
1804    config: &ResolvedConfig,
1805    files: &[discover::DiscoveredFile],
1806    workspaces: &[fallow_config::WorkspaceInfo],
1807    root_pkg: Option<&PackageJson>,
1808    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1809    config_candidates: &[std::path::PathBuf],
1810) -> Result<plugins::AggregatedPluginResult, FallowError> {
1811    let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1812    let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1813
1814    // The non-production config-discovery fast path: resolve plugin config
1815    // patterns against the files the discovery walk already collected (source
1816    // files unioned with non-source config candidates) instead of re-walking the
1817    // filesystem. Production keeps the filesystem path (no candidates captured).
1818    let candidate_index = (!config.production).then(|| {
1819        plugins::registry::ConfigCandidateIndex::build(
1820            file_paths
1821                .iter()
1822                .map(std::path::PathBuf::as_path)
1823                .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
1824        )
1825    });
1826
1827    let mut result = run_root_plugins(
1828        &registry,
1829        config,
1830        root_pkg,
1831        &file_paths,
1832        candidate_index.as_ref(),
1833    )?;
1834
1835    if workspaces.is_empty() {
1836        gate_auto_import_entry_patterns(&mut result, config, workspaces);
1837        return Ok(result);
1838    }
1839
1840    append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1841
1842    let ws_results = run_workspace_plugins(
1843        &registry,
1844        config,
1845        workspace_pkgs,
1846        &file_paths,
1847        &result.active_plugins,
1848        candidate_index.as_ref(),
1849    );
1850    merge_workspace_plugin_results(&mut result, ws_results)?;
1851
1852    gate_auto_import_entry_patterns(&mut result, config, workspaces);
1853
1854    Ok(result)
1855}
1856
1857type WorkspacePluginResult = Result<
1858    (plugins::AggregatedPluginResult, String),
1859    Vec<plugins::registry::PluginRegexValidationError>,
1860>;
1861
1862/// Run plugins for the root project and apply its package-file asset patterns.
1863fn run_root_plugins(
1864    registry: &plugins::PluginRegistry,
1865    config: &ResolvedConfig,
1866    root_pkg: Option<&PackageJson>,
1867    file_paths: &[std::path::PathBuf],
1868    candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
1869) -> Result<plugins::AggregatedPluginResult, FallowError> {
1870    let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
1871    let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1872        .iter()
1873        .map(std::path::PathBuf::as_path)
1874        .collect();
1875
1876    let mut result = if let Some(pkg) = root_pkg {
1877        registry
1878            .try_run_with_search_roots(
1879                pkg,
1880                &config.root,
1881                file_paths,
1882                &root_config_search_root_refs,
1883                config.production,
1884                candidate_index,
1885            )
1886            .map_err(|errors| {
1887                FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
1888            })?
1889    } else {
1890        plugins::AggregatedPluginResult::default()
1891    };
1892    if let Some(pkg) = root_pkg {
1893        append_package_file_asset_patterns(&mut result, "", pkg);
1894    }
1895    Ok(result)
1896}
1897
1898/// Run plugins for every workspace package in parallel, returning per-workspace
1899/// results (or regex errors) for the caller to merge.
1900fn run_workspace_plugins(
1901    registry: &plugins::PluginRegistry,
1902    config: &ResolvedConfig,
1903    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1904    file_paths: &[std::path::PathBuf],
1905    root_active_plugins: &[String],
1906    candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
1907) -> Vec<WorkspacePluginResult> {
1908    let root_active_plugins: rustc_hash::FxHashSet<&str> =
1909        root_active_plugins.iter().map(String::as_str).collect();
1910
1911    let precompiled_matchers = registry.precompile_config_matchers();
1912    let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
1913
1914    workspace_pkgs
1915        .par_iter()
1916        .zip(workspace_relative_files.par_iter())
1917        .filter_map(|((ws, ws_pkg), relative_files)| {
1918            let ws_result =
1919                match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
1920                    pkg: ws_pkg,
1921                    root: &ws.root,
1922                    project_root: &config.root,
1923                    precompiled_config_matchers: &precompiled_matchers,
1924                    relative_files,
1925                    skip_config_plugins: &root_active_plugins,
1926                    production_mode: config.production,
1927                    candidate_index,
1928                }) {
1929                    Ok(result) => result,
1930                    Err(errors) => return Some(Err(errors)),
1931                };
1932            if ws_result.active_plugins.is_empty() {
1933                return None;
1934            }
1935            let ws_prefix = ws
1936                .root
1937                .strip_prefix(&config.root)
1938                .unwrap_or(&ws.root)
1939                .to_string_lossy()
1940                .into_owned();
1941            Some(Ok((ws_result, ws_prefix)))
1942        })
1943        .collect::<Vec<_>>()
1944}
1945
1946/// Merge per-workspace plugin results into the root result, surfacing any
1947/// accumulated regex errors as a single config error.
1948fn merge_workspace_plugin_results(
1949    result: &mut plugins::AggregatedPluginResult,
1950    ws_results: Vec<WorkspacePluginResult>,
1951) -> Result<(), FallowError> {
1952    let mut regex_errors = Vec::new();
1953    for ws_result in ws_results {
1954        match ws_result {
1955            Ok((mut ws_result, ws_prefix)) => {
1956                ws_result.apply_workspace_prefix(&ws_prefix);
1957                ws_result.config_patterns.clear();
1958                ws_result.script_used_packages.clear();
1959                result.merge_into(ws_result);
1960            }
1961            Err(mut errors) => regex_errors.append(&mut errors),
1962        }
1963    }
1964    if !regex_errors.is_empty() {
1965        return Err(FallowError::config(
1966            plugins::registry::format_plugin_regex_errors(&regex_errors),
1967        ));
1968    }
1969    Ok(())
1970}
1971
1972/// When `autoImports` is enabled, drop the modeled Nuxt convention entry
1973/// patterns so genuinely-unreferenced convention files are reported as
1974/// `unused-file`. Component and script fallbacks have separate conservative
1975/// config guards because custom `components:` and `imports:` settings affect
1976/// different convention surfaces.
1977fn gate_auto_import_entry_patterns(
1978    result: &mut plugins::AggregatedPluginResult,
1979    config: &ResolvedConfig,
1980    workspaces: &[fallow_config::WorkspaceInfo],
1981) {
1982    if !config.auto_imports {
1983        return;
1984    }
1985    if !result.active_plugins.iter().any(|name| name == "nuxt") {
1986        return;
1987    }
1988    let components_custom = plugins::nuxt::config_declares_components(&config.root)
1989        || workspaces
1990            .iter()
1991            .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
1992    let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
1993        || workspaces
1994            .iter()
1995            .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
1996    result.entry_patterns.retain(|(rule, plugin)| {
1997        if plugin != "nuxt" {
1998            return true;
1999        }
2000        if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2001            return false;
2002        }
2003        if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2004            return false;
2005        }
2006        true
2007    });
2008}
2009
2010fn bucket_files_by_workspace(
2011    workspace_pkgs: &[LoadedWorkspacePackage<'_>],
2012    file_paths: &[std::path::PathBuf],
2013) -> Vec<Vec<(std::path::PathBuf, String)>> {
2014    use rayon::prelude::*;
2015
2016    // Assign each file to its first matching workspace in parallel. On large
2017    // monorepos this is O(files x workspaces) prefix scans plus a path clone and
2018    // a relative-path allocation per file; doing it per file on one thread was a
2019    // measurable slice of the plugins stage. The assignment is independent per
2020    // file, so the only ordering contract to preserve is first-match-by-workspace-
2021    // declaration-order (the original `break`) and per-bucket file order, both of
2022    // which hold because the parallel map keeps file indexing and the bucket fill
2023    // below walks the assignments in original file order.
2024    let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2025        .par_iter()
2026        .map(|file_path| {
2027            workspace_pkgs
2028                .iter()
2029                .enumerate()
2030                .find_map(|(idx, (ws, _))| {
2031                    file_path.strip_prefix(&ws.root).ok().map(|relative| {
2032                        (
2033                            idx,
2034                            file_path.clone(),
2035                            relative.to_string_lossy().into_owned(),
2036                        )
2037                    })
2038                })
2039        })
2040        .collect();
2041
2042    let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
2043    for (idx, file_path, relative) in assignments.into_iter().flatten() {
2044        buckets[idx].push((file_path, relative));
2045    }
2046
2047    buckets
2048}
2049
2050fn collect_config_search_roots(
2051    root: &Path,
2052    file_paths: &[std::path::PathBuf],
2053) -> Vec<std::path::PathBuf> {
2054    let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2055    roots.insert(root.to_path_buf());
2056
2057    for file_path in file_paths {
2058        let mut current = file_path.parent();
2059        while let Some(dir) = current {
2060            if !dir.starts_with(root) {
2061                break;
2062            }
2063            roots.insert(dir.to_path_buf());
2064            if dir == root {
2065                break;
2066            }
2067            current = dir.parent();
2068        }
2069    }
2070
2071    let mut roots_vec: Vec<_> = roots.into_iter().collect();
2072    roots_vec.sort();
2073    roots_vec
2074}
2075
2076/// Run analysis on a project directory (with export usages for LSP Code Lens).
2077///
2078/// # Errors
2079///
2080/// Returns an error if config loading, file discovery, parsing, or analysis fails.
2081#[deprecated(
2082    since = "2.76.0",
2083    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."
2084)]
2085pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
2086    let config = default_config(root);
2087    #[expect(
2088        deprecated,
2089        reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
2090    )]
2091    analyze_with_usages(&config)
2092}
2093
2094/// Resolve the analysis config for a project, mirroring the CLI's `--config`
2095/// behavior when `config_path` is provided.
2096///
2097/// # Errors
2098///
2099/// Returns an error when an explicit config cannot be loaded or automatic
2100/// config discovery finds an invalid config.
2101pub fn config_for_project(
2102    root: &Path,
2103    config_path: Option<&Path>,
2104) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2105    let user_config = if let Some(path) = config_path {
2106        Some((
2107            fallow_config::FallowConfig::load(path)
2108                .map_err(|e| FallowError::config(format!("{e:#}")))?,
2109            path.to_path_buf(),
2110        ))
2111    } else {
2112        fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2113    };
2114
2115    let config = match user_config {
2116        Some((config, path)) => resolve_user_config(config, path, root)?,
2117        None => (
2118            fallow_config::FallowConfig::default().resolve(
2119                root.to_path_buf(),
2120                fallow_config::OutputFormat::Human,
2121                num_cpus(),
2122                false,
2123                true,
2124                None,
2125            ),
2126            None,
2127        ),
2128    };
2129
2130    Ok(config)
2131}
2132
2133/// Flatten the dead-code production flag, validate boundaries and rule packs,
2134/// then resolve a user-supplied config for LSP/programmatic callers.
2135fn resolve_user_config(
2136    mut config: fallow_config::FallowConfig,
2137    path: std::path::PathBuf,
2138    root: &Path,
2139) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2140    let dead_code_production = config
2141        .production
2142        .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2143    config.production = dead_code_production.into();
2144    config
2145        .validate_resolved_boundaries(root)
2146        .map_err(|errors| {
2147            let joined = errors
2148                .iter()
2149                .map(ToString::to_string)
2150                .collect::<Vec<_>>()
2151                .join("\n  - ");
2152            FallowError::config(format!("invalid boundary configuration:\n  - {joined}"))
2153        })?;
2154    fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
2155        let joined = errors
2156            .iter()
2157            .map(ToString::to_string)
2158            .collect::<Vec<_>>()
2159            .join("\n  - ");
2160        FallowError::config(format!("invalid rule pack:\n  - {joined}"))
2161    })?;
2162    Ok((
2163        config.resolve(
2164            root.to_path_buf(),
2165            fallow_config::OutputFormat::Human,
2166            num_cpus(),
2167            false,
2168            true, // quiet: LSP/programmatic callers don't need progress bars
2169            None, // LSP/programmatic embedders use the default cache cap
2170        ),
2171        Some(path),
2172    ))
2173}
2174
2175/// Create a default config for a project root.
2176///
2177/// `analyze_project` is the dead-code entry point used by the LSP and other
2178/// programmatic embedders. When the loaded config uses the per-analysis
2179/// production form (`production: { deadCode: true, ... }`), the production
2180/// flag must be flattened to the dead-code analysis here. Otherwise
2181/// `ResolvedConfig::resolve` calls `.global()` which returns false for the
2182/// per-analysis variant and the production-mode rule overrides
2183/// (`unused_dev_dependencies: off`, etc.) plus `resolved.production = true`
2184/// are silently dropped.
2185pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
2186    config_for_project(root, None).map_or_else(
2187        |_| {
2188            fallow_config::FallowConfig::default().resolve(
2189                root.to_path_buf(),
2190                fallow_config::OutputFormat::Human,
2191                num_cpus(),
2192                false,
2193                true,
2194                None,
2195            )
2196        },
2197        |(config, _)| config,
2198    )
2199}
2200
2201fn num_cpus() -> usize {
2202    std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
2203}
2204
2205#[cfg(test)]
2206mod tests {
2207    use super::{
2208        bucket_files_by_workspace, collect_config_search_roots,
2209        format_undeclared_workspace_warning, warn_undeclared_workspaces,
2210    };
2211    use std::path::{Path, PathBuf};
2212
2213    use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
2214
2215    fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
2216        WorkspaceDiagnostic::new(
2217            root,
2218            root.join(relative),
2219            WorkspaceDiagnosticKind::UndeclaredWorkspace,
2220        )
2221    }
2222
2223    #[test]
2224    fn undeclared_workspace_warning_is_singular_for_one_path() {
2225        let root = Path::new("/repo");
2226        let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
2227            .expect("warning should be rendered");
2228
2229        assert_eq!(
2230            warning,
2231            "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."
2232        );
2233    }
2234
2235    #[test]
2236    fn undeclared_workspace_warning_summarizes_many_paths() {
2237        let root = PathBuf::from("/repo");
2238        let diagnostics = [
2239            "examples/a",
2240            "examples/b",
2241            "examples/c",
2242            "examples/d",
2243            "examples/e",
2244            "examples/f",
2245        ]
2246        .into_iter()
2247        .map(|path| diag(&root, path))
2248        .collect::<Vec<_>>();
2249
2250        let warning = format_undeclared_workspace_warning(&root, &diagnostics)
2251            .expect("warning should be rendered");
2252
2253        assert_eq!(
2254            warning,
2255            "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."
2256        );
2257    }
2258
2259    #[test]
2260    fn collect_config_search_roots_includes_file_ancestors_once() {
2261        let root = PathBuf::from("/repo");
2262        let search_roots = collect_config_search_roots(
2263            &root,
2264            &[
2265                root.join("apps/query/src/main.ts"),
2266                root.join("packages/shared/lib/index.ts"),
2267            ],
2268        );
2269
2270        assert_eq!(
2271            search_roots,
2272            vec![
2273                root.clone(),
2274                root.join("apps"),
2275                root.join("apps/query"),
2276                root.join("apps/query/src"),
2277                root.join("packages"),
2278                root.join("packages/shared"),
2279                root.join("packages/shared/lib"),
2280            ]
2281        );
2282    }
2283
2284    #[test]
2285    fn bucket_files_by_workspace_uses_workspace_relative_paths() {
2286        let root = PathBuf::from("/repo");
2287        let ui = fallow_config::WorkspaceInfo {
2288            root: root.join("apps/ui"),
2289            name: "ui".to_string(),
2290            is_internal_dependency: false,
2291        };
2292        let api = fallow_config::WorkspaceInfo {
2293            root: root.join("apps/api"),
2294            name: "api".to_string(),
2295            is_internal_dependency: false,
2296        };
2297        let workspace_pkgs = vec![
2298            (
2299                &ui,
2300                fallow_config::PackageJson {
2301                    name: Some("ui".to_string()),
2302                    ..Default::default()
2303                },
2304            ),
2305            (
2306                &api,
2307                fallow_config::PackageJson {
2308                    name: Some("api".to_string()),
2309                    ..Default::default()
2310                },
2311            ),
2312        ];
2313        let files = vec![
2314            root.join("apps/ui/vite.config.ts"),
2315            root.join("apps/ui/src/main.ts"),
2316            root.join("apps/api/src/server.ts"),
2317            root.join("tools/build.ts"),
2318        ];
2319
2320        let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
2321
2322        assert_eq!(
2323            buckets[0],
2324            vec![
2325                (
2326                    root.join("apps/ui/vite.config.ts"),
2327                    "vite.config.ts".to_string()
2328                ),
2329                (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
2330            ]
2331        );
2332        assert_eq!(
2333            buckets[1],
2334            vec![(
2335                root.join("apps/api/src/server.ts"),
2336                "src/server.ts".to_string()
2337            )]
2338        );
2339    }
2340
2341    #[test]
2342    fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
2343        let dir = tempfile::tempdir().expect("create temp dir");
2344        let pkg_good = dir.path().join("packages").join("good");
2345        let pkg_bad = dir.path().join("packages").join("bad");
2346        std::fs::create_dir_all(&pkg_good).unwrap();
2347        std::fs::create_dir_all(&pkg_bad).unwrap();
2348        std::fs::write(
2349            dir.path().join("package.json"),
2350            r#"{"workspaces": ["packages/*"]}"#,
2351        )
2352        .unwrap();
2353        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2354        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2355
2356        let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
2357            dir.path(),
2358            &globset::GlobSet::empty(),
2359        )
2360        .expect("root package.json is valid");
2361        assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
2362        fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
2363
2364        warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
2365
2366        let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2367        let mut malformed = 0;
2368        let mut undeclared_for_bad = 0;
2369        for diag in &diagnostics {
2370            if matches!(
2371                diag.kind,
2372                WorkspaceDiagnosticKind::MalformedPackageJson { .. }
2373            ) && diag.path.ends_with("bad")
2374            {
2375                malformed += 1;
2376            }
2377            if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
2378                && diag.path.ends_with("bad")
2379            {
2380                undeclared_for_bad += 1;
2381            }
2382        }
2383        assert_eq!(
2384            malformed, 1,
2385            "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
2386        );
2387        assert_eq!(
2388            undeclared_for_bad, 0,
2389            "warn_undeclared_workspaces must NOT re-flag a path that already \
2390             carries MalformedPackageJson; got duplicates: {diagnostics:?}"
2391        );
2392    }
2393}