Skip to main content

fallow_core/
lib.rs

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