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_api` (e.g. `run_dead_code`,
4//! `run_boundary_violations`, `run_duplication`, `run_health`). The typed
5//! `run_*` functions are the primary embedder contract; serialize typed output
6//! with the matching `serialize_*_programmatic_json` helper only at a protocol
7//! boundary. See `docs/fallow-core-migration.md`
8//! for the function-by-function migration map. Items in this crate may change
9//! in any release, including patch releases. Publishing remains transitional
10//! while `fallow-engine` still depends on core internals.
11
12#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
13#![cfg_attr(
14    test,
15    allow(
16        clippy::unwrap_used,
17        clippy::expect_used,
18        reason = "tests use unwrap and expect to keep fixture setup concise"
19    )
20)]
21
22pub mod analyze;
23pub mod cache;
24pub mod discover;
25pub(crate) mod errors;
26mod external_style_usage;
27pub mod extract;
28pub mod git_env;
29mod package_assets;
30pub mod plugins;
31pub(crate) mod progress;
32pub mod results;
33pub(crate) mod scripts;
34/// Public hook for the fuzz harness (fuzz/fuzz_targets/fuzz_scripts.rs) only;
35/// not a supported API. The module itself stays crate-private.
36#[doc(hidden)]
37pub use scripts::parse_script;
38pub mod suppress;
39
40pub use fallow_graph::cache as graph_cache;
41pub use fallow_graph::graph;
42pub use fallow_graph::project;
43pub use fallow_graph::resolve;
44
45use std::path::{Path, PathBuf};
46use std::time::Instant;
47
48use errors::FallowError;
49use fallow_config::{
50    EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces_with_diagnostics,
51    find_undeclared_workspaces_with_ignores,
52};
53use fallow_types::trace::PipelineTimings;
54use rayon::prelude::*;
55use results::AnalysisResults;
56use rustc_hash::FxHashSet;
57
58const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
59type LoadedWorkspacePackage = (fallow_config::WorkspaceInfo, PackageJson);
60
61fn record_graph_package_usage(
62    graph: &mut graph::ModuleGraph,
63    package_name: &str,
64    file_id: discover::FileId,
65    is_type_only: bool,
66) {
67    graph
68        .package_usage
69        .entry(package_name.to_owned())
70        .or_default()
71        .push(file_id);
72    if is_type_only {
73        graph
74            .type_only_package_usage
75            .entry(package_name.to_owned())
76            .or_default()
77            .push(file_id);
78    }
79}
80
81fn workspace_package_name<'a>(
82    source: &str,
83    workspace_names: &FxHashSet<&'a str>,
84) -> Option<&'a str> {
85    if !resolve::is_bare_specifier(source) {
86        return None;
87    }
88    let package_name = resolve::extract_package_name(source);
89    workspace_names.get(package_name.as_str()).copied()
90}
91
92fn credit_workspace_package_usage(
93    graph: &mut graph::ModuleGraph,
94    resolved: &[resolve::ResolvedModule],
95    workspaces: &[fallow_config::WorkspaceInfo],
96) {
97    if workspaces.is_empty() {
98        return;
99    }
100
101    let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
102    for module in resolved {
103        for import in module.all_resolved_imports() {
104            if matches!(
105                import.target,
106                resolve::ResolveResult::InternalModule(_)
107                    | resolve::ResolveResult::CommonJsInternalModule(_)
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.
145#[doc(hidden)]
146pub struct AnalysisOutput {
147    pub results: AnalysisResults,
148    pub timings: Option<PipelineTimings>,
149    pub graph: Option<graph::ModuleGraph>,
150    /// Parsed modules from the pipeline, available when `retain_modules` is true.
151    /// Used by combined and LSP flows to share downstream module data.
152    /// Graph-only extraction payloads are released after graph construction.
153    pub modules: Option<Vec<extract::ModuleInfo>>,
154    /// Discovered files from the pipeline, available when `retain_modules` is true.
155    pub files: Option<Vec<discover::DiscoveredFile>>,
156    /// Package names invoked from package.json scripts and CI configs, mirroring
157    /// what the unused-deps detector consults. Populated for every pipeline run;
158    /// trace tooling reads it so `trace_dependency` agrees with `unused-deps` on
159    /// "used vs unused" instead of returning false-negatives for script-only deps.
160    pub script_used_packages: rustc_hash::FxHashSet<String>,
161    /// xxh3 content hash of every parsed source file, keyed by absolute path.
162    /// Used by `fallow fix` to detect on-disk drift between the in-process
163    /// analysis read and the per-file write; if the file's current hash
164    /// differs from the captured value, the fix for that file is skipped
165    /// with a clear diagnostic and exit 2. The hash is the same value
166    /// extract/cache uses for cache invalidation, so a cached parse contributes
167    /// the same hash as a fresh parse. Roughly 8 bytes per file (negligible
168    /// memory cost even on 100k-file projects).
169    pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
170}
171
172/// Parse/cache phase metrics supplied by callers that own parsing before
173/// handing modules back to the core detector backend.
174#[derive(Debug, Clone, Copy)]
175#[doc(hidden)]
176pub struct AnalysisParseMetrics {
177    parse_ms: f64,
178    cache_ms: f64,
179    cache_hits: usize,
180    cache_misses: usize,
181    parse_cpu_ms: f64,
182}
183
184/// Update cache: write freshly parsed modules and refresh stale mtime/size entries.
185fn update_cache(
186    store: &mut cache::CacheStore,
187    modules: &[extract::ModuleInfo],
188    files: &[discover::DiscoveredFile],
189) -> bool {
190    let mut dirty = false;
191    for module in modules {
192        if let Some(file) = files.get(module.file_id.0 as usize) {
193            let fingerprint = file_fingerprint(&file.path);
194            if let Some(cached) = store.get_by_path_only(&file.path)
195                && cached.content_hash == module.content_hash
196            {
197                if cached.source_fingerprint() != fingerprint {
198                    let preserved_last_access = cached.last_access_secs;
199                    let mut refreshed = cache::module_to_cached(module, fingerprint);
200                    refreshed.last_access_secs = preserved_last_access;
201                    store.insert(&file.path, refreshed);
202                    dirty = true;
203                }
204                continue;
205            }
206            store.insert(&file.path, cache::module_to_cached(module, fingerprint));
207            dirty = true;
208        }
209    }
210    let removed_stale_paths = store.retain_paths(files);
211    dirty || removed_stale_paths
212}
213
214/// Resolve `config.cache_max_size_mb` into bytes, falling back to the
215/// extract crate's `DEFAULT_CACHE_MAX_SIZE`. Lives at this layer (not on
216/// `ResolvedConfig`) because `fallow-config` does not depend on
217/// `fallow-extract`; the bytes conversion is owned by the cache callsite.
218/// Public so CLI subcommands that load the cache directly (`flags`,
219/// `health`, `coverage analyze`) can call it without re-deriving the
220/// same fallback policy.
221#[must_use]
222fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
223    config
224        .cache_max_size_mb
225        .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
226            (mb as usize).saturating_mul(1024 * 1024)
227        })
228}
229
230/// Extract source fingerprint metadata from a path.
231fn file_fingerprint(path: &std::path::Path) -> fallow_types::source_fingerprint::SourceFingerprint {
232    std::fs::metadata(path).map_or(
233        fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
234        |metadata| fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata),
235    )
236}
237
238fn format_undeclared_workspace_warning(
239    root: &Path,
240    undeclared: &[fallow_config::WorkspaceDiagnostic],
241) -> Option<String> {
242    if undeclared.is_empty() {
243        return None;
244    }
245
246    let preview = undeclared
247        .iter()
248        .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
249        .map(|diag| {
250            diag.path
251                .strip_prefix(root)
252                .unwrap_or(&diag.path)
253                .display()
254                .to_string()
255                .replace('\\', "/")
256        })
257        .collect::<Vec<_>>();
258    let remaining = undeclared
259        .len()
260        .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
261    let tail = if remaining > 0 {
262        format!(" (and {remaining} more)")
263    } else {
264        String::new()
265    };
266    let noun = if undeclared.len() == 1 {
267        "directory with package.json is"
268    } else {
269        "directories with package.json are"
270    };
271    let guidance = if undeclared.len() == 1 {
272        "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
273    } else {
274        "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
275    };
276
277    Some(format!(
278        "{} {} not declared as {}: {}{}. {}",
279        undeclared.len(),
280        noun,
281        if undeclared.len() == 1 {
282            "a workspace"
283        } else {
284            "workspaces"
285        },
286        preview.join(", "),
287        tail,
288        guidance
289    ))
290}
291
292fn warn_undeclared_workspaces(
293    root: &Path,
294    workspaces_vec: &[fallow_config::WorkspaceInfo],
295    ignore_patterns: &globset::GlobSet,
296    quiet: bool,
297) {
298    let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
299    if undeclared.is_empty() {
300        return;
301    }
302
303    let existing = fallow_config::workspace_diagnostics_for(root);
304    let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
305        .iter()
306        .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
307        .collect();
308    let undeclared: Vec<_> = undeclared
309        .into_iter()
310        .filter(|diag| {
311            let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
312            !already_flagged.contains(&canonical)
313        })
314        .collect();
315    if undeclared.is_empty() {
316        return;
317    }
318
319    fallow_config::append_workspace_diagnostics(root, undeclared.clone());
320
321    if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
322        tracing::warn!("{message}");
323    }
324}
325
326/// Run the full analysis pipeline.
327///
328/// # Errors
329///
330/// Returns an error if file discovery, parsing, or analysis fails.
331#[doc(hidden)]
332#[deprecated(
333    since = "2.76.0",
334    note = "fallow_core is internal; use fallow_api::run_dead_code for typed output; serialize with fallow_api::serialize_dead_code_programmatic_json for JSON output. See docs/fallow-core-migration.md."
335)]
336pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
337    let output = analyze_full(config, false, false, false, false)?;
338    Ok(output.results)
339}
340
341/// Run the full analysis pipeline with export usage collection (for LSP Code Lens).
342///
343/// # Errors
344///
345/// Returns an error if file discovery, parsing, or analysis fails.
346#[doc(hidden)]
347#[deprecated(
348    since = "2.76.0",
349    note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: export-usage collection is not exposed in the programmatic surface today. See docs/fallow-core-migration.md."
350)]
351pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
352    let output = analyze_full(config, false, true, false, false)?;
353    Ok(output.results)
354}
355
356/// Run the full analysis pipeline with optional performance timings and graph retention.
357///
358/// # Errors
359///
360/// Returns an error if file discovery, parsing, or analysis fails.
361#[doc(hidden)]
362#[deprecated(
363    since = "2.76.0",
364    note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. 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."
365)]
366pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
367    analyze_full(config, true, false, false, false)
368}
369
370/// Run the full analysis pipeline, retaining parsed modules and discovered files.
371///
372/// Used by the combined command to share a single parse across dead-code and health.
373/// When `need_complexity` is true, the `ComplexityVisitor` runs during parsing so
374/// the returned modules contain per-function complexity data.
375///
376/// # Errors
377///
378/// Returns an error if file discovery, parsing, or analysis fails.
379#[doc(hidden)]
380#[deprecated(
381    since = "2.76.0",
382    note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: combined-mode module retention is not exposed in the programmatic surface today. See docs/fallow-core-migration.md."
383)]
384pub fn analyze_retaining_modules(
385    config: &ResolvedConfig,
386    need_complexity: bool,
387    retain_graph: bool,
388) -> Result<AnalysisOutput, FallowError> {
389    analyze_full(config, retain_graph, false, need_complexity, true)
390}
391
392fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
393    let show_progress = !config.quiet
394        && std::io::IsTerminal::is_terminal(&std::io::stderr())
395        && matches!(
396            config.output,
397            fallow_config::OutputFormat::Human
398                | fallow_config::OutputFormat::Compact
399                | fallow_config::OutputFormat::Markdown
400        );
401    progress::AnalysisProgress::new(show_progress)
402}
403
404fn warn_missing_node_modules(config: &ResolvedConfig) {
405    if config.root.join("node_modules").is_dir() {
406        return;
407    }
408    if fallow_config::is_deno_without_node_modules(&config.root) {
409        return;
410    }
411
412    tracing::warn!(
413        "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
414    );
415}
416
417fn discover_analysis_workspaces(
418    config: &ResolvedConfig,
419) -> Result<(Vec<fallow_config::WorkspaceInfo>, f64), FallowError> {
420    let t = Instant::now();
421    let (workspaces, diagnostics) =
422        discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
423            .map_err(|error| FallowError::config(error.to_string()))?;
424    fallow_config::stash_workspace_diagnostics(&config.root, diagnostics);
425    let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
426    if !workspaces.is_empty() {
427        tracing::info!(count = workspaces.len(), "workspaces discovered");
428    }
429
430    warn_undeclared_workspaces(
431        &config.root,
432        &workspaces,
433        &config.ignore_patterns,
434        config.quiet,
435    );
436
437    Ok((workspaces, workspaces_ms))
438}
439
440/// Owned products of the shared pipeline prelude: progress reporter, project
441/// state (owns discovered files and workspaces), root package.json, and the
442/// discovery/workspace timings.
443struct AnalysisSetup {
444    progress: progress::AnalysisProgress,
445    project: project::ProjectState,
446    root_pkg: Option<PackageJson>,
447    /// Non-source config-candidate files captured by the same discovery walk,
448    /// used to resolve plugin config patterns in-memory (empty in production
449    /// mode, where the filesystem path is kept). Carried alongside `project`
450    /// rather than inside it to avoid churning `ProjectState`'s many callers.
451    config_candidates: Vec<std::path::PathBuf>,
452    discover_ms: f64,
453    workspaces_ms: f64,
454}
455
456/// Reusable discovery prelude for a resolved project.
457///
458/// This carries the file registry plus the workspace and config-candidate state
459/// that plugin detection needs, so engine sessions can run several analyses
460/// over one stable discovery boundary without re-walking the project.
461#[derive(Debug, Clone)]
462#[doc(hidden)]
463pub struct AnalysisDiscovery {
464    files: Vec<discover::DiscoveredFile>,
465    workspaces: Vec<fallow_config::WorkspaceInfo>,
466    root_pkg: Option<PackageJson>,
467    config_candidates: Vec<std::path::PathBuf>,
468    discover_ms: f64,
469    workspaces_ms: f64,
470}
471
472impl AnalysisDiscovery {
473    /// Build a discovery prelude from an engine-owned discovery run.
474    #[must_use]
475    pub fn from_parts(
476        files: Vec<discover::DiscoveredFile>,
477        workspaces: Vec<fallow_config::WorkspaceInfo>,
478        root_pkg: Option<PackageJson>,
479        config_candidates: Vec<std::path::PathBuf>,
480        discover_ms: f64,
481        workspaces_ms: f64,
482    ) -> Self {
483        Self {
484            files,
485            workspaces,
486            root_pkg,
487            config_candidates,
488            discover_ms,
489            workspaces_ms,
490        }
491    }
492
493    /// Discovered source files, indexed by stable `FileId` for this session.
494    #[must_use]
495    fn files(&self) -> &[discover::DiscoveredFile] {
496        &self.files
497    }
498
499    /// Discovered workspace packages for this session.
500    #[must_use]
501    pub fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
502        &self.workspaces
503    }
504
505    /// Consume this discovery prelude and return its source file registry.
506    #[must_use]
507    pub fn into_files(self) -> Vec<discover::DiscoveredFile> {
508        self.files
509    }
510}
511
512/// Owned state shared across one legacy core analysis run.
513///
514/// Engine-owned sessions use `fallow-engine`; this remains only for deprecated
515/// core entrypoints while core is being narrowed to detector/backend helpers.
516pub(crate) struct AnalysisSession<'a> {
517    config: &'a ResolvedConfig,
518    pipeline_start: Instant,
519    progress: progress::AnalysisProgress,
520    project: project::ProjectState,
521    root_pkg: Option<PackageJson>,
522    config_candidates: Vec<std::path::PathBuf>,
523    discover_ms: f64,
524    workspaces_ms: f64,
525}
526
527impl<'a> AnalysisSession<'a> {
528    fn new(config: &'a ResolvedConfig) -> Result<Self, FallowError> {
529        let pipeline_start = Instant::now();
530        let AnalysisSetup {
531            progress,
532            project,
533            root_pkg,
534            config_candidates,
535            discover_ms,
536            workspaces_ms,
537        } = run_analysis_setup(config)?;
538
539        Ok(Self {
540            config,
541            pipeline_start,
542            progress,
543            project,
544            root_pkg,
545            config_candidates,
546            discover_ms,
547            workspaces_ms,
548        })
549    }
550
551    fn files(&self) -> &[discover::DiscoveredFile] {
552        self.project.files()
553    }
554
555    fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
556        self.project.workspaces()
557    }
558
559    fn load_workspace_packages(&self) -> Vec<LoadedWorkspacePackage> {
560        load_workspace_packages(self.workspaces())
561    }
562
563    fn run_plugins_and_scripts(
564        &self,
565        workspace_pkgs: &[LoadedWorkspacePackage],
566    ) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
567        run_plugins_and_scripts(&PluginScriptInput {
568            config: self.config,
569            progress: &self.progress,
570            files: self.files(),
571            workspaces: self.workspaces(),
572            root_pkg: self.root_pkg.as_ref(),
573            workspace_pkgs,
574            config_candidates: &self.config_candidates,
575        })
576    }
577
578    fn prelude_timings(&self, plugins_ms: f64, scripts_ms: f64) -> PreludeTimings {
579        PreludeTimings {
580            discover_ms: self.discover_ms,
581            workspaces_ms: self.workspaces_ms,
582            plugins_ms,
583            scripts_ms,
584        }
585    }
586
587    fn parse_modules(&self, need_complexity: bool) -> AnalysisParseOutput {
588        let t = Instant::now();
589        self.progress
590            .set_stage(&format!("parsing {} files...", self.files().len()));
591        parse_analysis_modules(self.config, self.files(), need_complexity, t)
592    }
593
594    fn run_owned_core(
595        &self,
596        workspace_pkgs: &[LoadedWorkspacePackage],
597        plugin_result: &plugins::AggregatedPluginResult,
598        mut modules: Vec<extract::ModuleInfo>,
599        collect_usages: bool,
600    ) -> OwnedAnalysisCore {
601        let shared = AnalysisCoreSharedInput {
602            config: self.config,
603            progress: &self.progress,
604            files: self.files(),
605            workspaces: self.workspaces(),
606            root_pkg: self.root_pkg.as_ref(),
607            workspace_pkgs,
608            plugin_result,
609        };
610
611        let entry_points = discover_analysis_entry_points(&shared);
612        let (resolved, graph) =
613            if let Some(hit) = try_load_analysis_graph_cache(&shared, &entry_points, &modules) {
614                (
615                    TimedResolvedModules {
616                        project: hit.project,
617                        elapsed_ms: 0.0,
618                    },
619                    TimedGraph {
620                        graph: hit.graph,
621                        elapsed_ms: hit.elapsed_ms,
622                    },
623                )
624            } else {
625                let resolved = resolve_analysis_imports_timed(&shared, &modules);
626                let graph =
627                    build_analysis_graph_timed(&shared, &resolved.project, &entry_points, &modules);
628                (resolved, graph)
629            };
630        release_resolution_payloads(&mut modules);
631        let analysis = analyze_dead_code_timed(
632            &shared,
633            &graph.graph,
634            &resolved.project.modules,
635            &modules,
636            collect_usages,
637            entry_points.summary,
638        );
639
640        OwnedAnalysisCore {
641            result: analysis.result,
642            graph: graph.graph,
643            modules,
644            entry_point_count: entry_points.count,
645            entry_points_ms: entry_points.elapsed_ms,
646            resolve_ms: resolved.elapsed_ms,
647            graph_ms: graph.elapsed_ms,
648            analyze_ms: analysis.elapsed_ms,
649        }
650    }
651
652    fn run_full(
653        self,
654        retain: bool,
655        collect_usages: bool,
656        need_complexity: bool,
657        retain_modules: bool,
658    ) -> Result<AnalysisOutput, FallowError> {
659        let workspace_pkgs = self.load_workspace_packages();
660        let (plugin_result, plugins_ms, scripts_ms) =
661            self.run_plugins_and_scripts(&workspace_pkgs)?;
662
663        let AnalysisParseOutput { modules, metrics } = self.parse_modules(need_complexity);
664        let core = self.run_owned_core(&workspace_pkgs, &plugin_result, modules, collect_usages);
665        self.progress.finish();
666
667        let profile = full_analysis_pipeline_profile(
668            &self.prelude_timings(plugins_ms, scripts_ms),
669            self.pipeline_start,
670            self.files(),
671            self.workspaces(),
672            &core,
673            &metrics,
674        );
675        trace_pipeline_profile(&profile);
676
677        Ok(assemble_full_output(
678            core,
679            plugin_result,
680            &profile,
681            self.files(),
682            retain,
683            retain_modules,
684        ))
685    }
686}
687
688/// Run the shared prelude: progress setup, node_modules check, workspace and
689/// root-package discovery, hidden-dir scoping, and file discovery.
690fn run_analysis_setup(config: &ResolvedConfig) -> Result<AnalysisSetup, FallowError> {
691    let progress = new_analysis_progress(config);
692    warn_missing_node_modules(config);
693
694    let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config)?;
695    let root_pkg = load_root_package_json(config);
696    let discovery_hidden_dir_scopes =
697        discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
698
699    let t = Instant::now();
700    progress.set_stage("discovering files...");
701    let (discovered_files, config_candidates) =
702        discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
703    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
704
705    let project = project::ProjectState::new(discovered_files, workspaces_vec);
706
707    Ok(AnalysisSetup {
708        progress,
709        project,
710        root_pkg,
711        config_candidates,
712        discover_ms,
713        workspaces_ms,
714    })
715}
716
717/// Borrowed inputs for plugin detection and script analysis.
718struct PluginScriptInput<'a> {
719    config: &'a ResolvedConfig,
720    progress: &'a progress::AnalysisProgress,
721    files: &'a [discover::DiscoveredFile],
722    workspaces: &'a [fallow_config::WorkspaceInfo],
723    root_pkg: Option<&'a PackageJson>,
724    workspace_pkgs: &'a [LoadedWorkspacePackage],
725    config_candidates: &'a [std::path::PathBuf],
726}
727
728/// Run plugin detection and package.json/CI script analysis, returning the
729/// aggregated plugin result plus the two phase timings.
730fn run_plugins_and_scripts(
731    input: &PluginScriptInput<'_>,
732) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
733    let t = Instant::now();
734    input.progress.set_stage("detecting plugins...");
735    let mut plugin_result = run_plugins(
736        input.config,
737        input.files,
738        input.workspaces,
739        input.root_pkg,
740        input.workspace_pkgs,
741        input.config_candidates,
742    )?;
743    let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
744
745    let t = Instant::now();
746    analyze_all_scripts(
747        input.config,
748        input.workspaces,
749        input.root_pkg,
750        input.workspace_pkgs,
751        &mut plugin_result,
752    );
753    let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
754
755    Ok((plugin_result, plugins_ms, scripts_ms))
756}
757
758/// Timings captured by the dead-code backend prelude.
759#[derive(Debug, Clone, Copy)]
760#[doc(hidden)]
761pub struct DeadCodePreludeTimings {
762    pub discover_ms: f64,
763    pub workspaces_ms: f64,
764    pub plugins_ms: f64,
765    pub scripts_ms: f64,
766}
767
768/// Opaque backend prelude for engine-owned dead-code orchestration.
769///
770/// The engine owns the phase ordering. Core keeps the detector/backend state
771/// needed by those phases private.
772#[doc(hidden)]
773pub struct DeadCodeBackendPrelude<'a> {
774    config: &'a ResolvedConfig,
775    pipeline_start: Instant,
776    progress: progress::AnalysisProgress,
777    discovery: AnalysisDiscovery,
778    workspace_pkgs: Vec<LoadedWorkspacePackage>,
779    plugin_result: plugins::AggregatedPluginResult,
780    plugins_ms: f64,
781    scripts_ms: f64,
782}
783
784impl DeadCodeBackendPrelude<'_> {
785    #[must_use]
786    pub fn timings(&self) -> DeadCodePreludeTimings {
787        DeadCodePreludeTimings {
788            discover_ms: self.discovery.discover_ms,
789            workspaces_ms: self.discovery.workspaces_ms,
790            plugins_ms: self.plugins_ms,
791            scripts_ms: self.scripts_ms,
792        }
793    }
794
795    #[must_use]
796    pub fn elapsed_ms(&self) -> f64 {
797        self.pipeline_start.elapsed().as_secs_f64() * 1000.0
798    }
799
800    #[must_use]
801    pub fn script_used_packages(&self) -> FxHashSet<String> {
802        self.plugin_result.script_used_packages.clone()
803    }
804
805    pub fn finish(&self) {
806        self.progress.finish();
807    }
808}
809
810/// Entry-point discovery result for an engine-owned dead-code pipeline.
811#[doc(hidden)]
812pub struct DeadCodeEntryPoints {
813    inner: TimedEntryPoints,
814}
815
816impl DeadCodeEntryPoints {
817    #[must_use]
818    pub fn count(&self) -> usize {
819        self.inner.count
820    }
821
822    #[must_use]
823    pub fn elapsed_ms(&self) -> f64 {
824        self.inner.elapsed_ms
825    }
826}
827
828/// Import-resolution result for an engine-owned dead-code pipeline.
829#[doc(hidden)]
830pub struct DeadCodeResolvedModules {
831    pub project: resolve::ResolvedProject,
832    pub elapsed_ms: f64,
833}
834
835/// Graph build or graph-cache result for an engine-owned dead-code pipeline.
836#[doc(hidden)]
837pub struct DeadCodeGraphRun {
838    pub graph: graph::ModuleGraph,
839    pub elapsed_ms: f64,
840}
841
842/// Detector result for an engine-owned dead-code pipeline.
843#[doc(hidden)]
844pub struct DeadCodeDetectorRun {
845    pub results: AnalysisResults,
846    pub elapsed_ms: f64,
847}
848
849/// Prepare plugin and script context for engine-owned dead-code orchestration.
850///
851/// # Errors
852///
853/// Returns an error if plugin detection fails.
854pub fn prepare_dead_code_backend_prelude(
855    config: &ResolvedConfig,
856    discovery: AnalysisDiscovery,
857) -> Result<DeadCodeBackendPrelude<'_>, FallowError> {
858    let progress = new_analysis_progress(config);
859    let pipeline_start = Instant::now();
860    let workspace_pkgs = load_workspace_packages(&discovery.workspaces);
861    let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
862        config,
863        progress: &progress,
864        files: discovery.files(),
865        workspaces: &discovery.workspaces,
866        root_pkg: discovery.root_pkg.as_ref(),
867        workspace_pkgs: &workspace_pkgs,
868        config_candidates: &discovery.config_candidates,
869    })?;
870
871    Ok(DeadCodeBackendPrelude {
872        config,
873        pipeline_start,
874        progress,
875        discovery,
876        workspace_pkgs,
877        plugin_result,
878        plugins_ms,
879        scripts_ms,
880    })
881}
882
883/// Discover entry points for an engine-owned dead-code pipeline.
884#[must_use]
885pub fn discover_dead_code_entry_points(
886    prelude: &DeadCodeBackendPrelude<'_>,
887) -> DeadCodeEntryPoints {
888    let shared = prelude.shared_input();
889    DeadCodeEntryPoints {
890        inner: discover_analysis_entry_points(&shared),
891    }
892}
893
894/// Try loading the graph cache for an engine-owned dead-code pipeline.
895#[must_use]
896pub fn try_load_dead_code_graph_cache(
897    prelude: &DeadCodeBackendPrelude<'_>,
898    entry_points: &DeadCodeEntryPoints,
899    modules: &[extract::ModuleInfo],
900) -> Option<(DeadCodeResolvedModules, DeadCodeGraphRun)> {
901    let shared = prelude.shared_input();
902    try_load_analysis_graph_cache(&shared, &entry_points.inner, modules).map(|hit| {
903        (
904            DeadCodeResolvedModules {
905                project: hit.project,
906                elapsed_ms: 0.0,
907            },
908            DeadCodeGraphRun {
909                graph: hit.graph,
910                elapsed_ms: hit.elapsed_ms,
911            },
912        )
913    })
914}
915
916/// Resolve imports for an engine-owned dead-code pipeline.
917#[must_use]
918pub fn resolve_dead_code_imports(
919    prelude: &DeadCodeBackendPrelude<'_>,
920    modules: &[extract::ModuleInfo],
921) -> DeadCodeResolvedModules {
922    let shared = prelude.shared_input();
923    let resolved = resolve_analysis_imports_timed(&shared, modules);
924    DeadCodeResolvedModules {
925        project: resolved.project,
926        elapsed_ms: resolved.elapsed_ms,
927    }
928}
929
930/// Build the module graph for an engine-owned dead-code pipeline.
931#[must_use]
932pub fn build_dead_code_graph(
933    prelude: &DeadCodeBackendPrelude<'_>,
934    project: &resolve::ResolvedProject,
935    entry_points: &DeadCodeEntryPoints,
936    modules: &[extract::ModuleInfo],
937) -> DeadCodeGraphRun {
938    let shared = prelude.shared_input();
939    let graph = build_analysis_graph_timed(&shared, project, &entry_points.inner, modules);
940    DeadCodeGraphRun {
941        graph: graph.graph,
942        elapsed_ms: graph.elapsed_ms,
943    }
944}
945
946/// Run the dead-code detectors for an engine-owned pipeline.
947#[must_use]
948pub fn run_dead_code_detectors(
949    prelude: &DeadCodeBackendPrelude<'_>,
950    graph: &graph::ModuleGraph,
951    resolved: &[resolve::ResolvedModule],
952    modules: &[extract::ModuleInfo],
953    collect_usages: bool,
954    entry_points: &DeadCodeEntryPoints,
955) -> DeadCodeDetectorRun {
956    let shared = prelude.shared_input();
957    let analysis = analyze_dead_code_timed(
958        &shared,
959        graph,
960        resolved,
961        modules,
962        collect_usages,
963        entry_points.inner.summary.clone(),
964    );
965    DeadCodeDetectorRun {
966        results: analysis.result,
967        elapsed_ms: analysis.elapsed_ms,
968    }
969}
970
971impl<'a> DeadCodeBackendPrelude<'a> {
972    fn shared_input(&'a self) -> AnalysisCoreSharedInput<'a> {
973        AnalysisCoreSharedInput {
974            config: self.config,
975            progress: &self.progress,
976            files: self.discovery.files(),
977            workspaces: &self.discovery.workspaces,
978            root_pkg: self.discovery.root_pkg.as_ref(),
979            workspace_pkgs: &self.workspace_pkgs,
980            plugin_result: &self.plugin_result,
981        }
982    }
983}
984
985/// Prelude/aggregate metrics shared between the parse and reuse pipeline paths
986/// when assembling the `PipelineProfile`.
987struct PreludeMetrics {
988    discover_ms: f64,
989    workspaces_ms: f64,
990    plugins_ms: f64,
991    scripts_ms: f64,
992    total_ms: f64,
993    file_count: usize,
994    workspace_count: usize,
995    module_count: usize,
996}
997
998/// The four prelude phase timings (discovery through script analysis).
999#[expect(
1000    clippy::struct_field_names,
1001    reason = "timings are all milliseconds; the _ms suffix is the unit"
1002)]
1003struct PreludeTimings {
1004    discover_ms: f64,
1005    workspaces_ms: f64,
1006    plugins_ms: f64,
1007    scripts_ms: f64,
1008}
1009
1010/// Build `PreludeMetrics` from the prelude timings, pipeline start instant, and
1011/// the discovered file/workspace/module counts.
1012fn prelude_metrics(
1013    timings: &PreludeTimings,
1014    pipeline_start: Instant,
1015    files: &[discover::DiscoveredFile],
1016    workspaces: &[fallow_config::WorkspaceInfo],
1017    module_count: usize,
1018) -> PreludeMetrics {
1019    PreludeMetrics {
1020        discover_ms: timings.discover_ms,
1021        workspaces_ms: timings.workspaces_ms,
1022        plugins_ms: timings.plugins_ms,
1023        scripts_ms: timings.scripts_ms,
1024        total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
1025        file_count: files.len(),
1026        workspace_count: workspaces.len(),
1027        module_count,
1028    }
1029}
1030
1031struct AnalysisCoreSharedInput<'a> {
1032    config: &'a ResolvedConfig,
1033    progress: &'a progress::AnalysisProgress,
1034    files: &'a [discover::DiscoveredFile],
1035    workspaces: &'a [fallow_config::WorkspaceInfo],
1036    root_pkg: Option<&'a PackageJson>,
1037    workspace_pkgs: &'a [LoadedWorkspacePackage],
1038    plugin_result: &'a plugins::AggregatedPluginResult,
1039}
1040
1041struct TimedEntryPoints {
1042    entry_points: discover::CategorizedEntryPoints,
1043    summary: results::EntryPointSummary,
1044    count: usize,
1045    elapsed_ms: f64,
1046}
1047
1048struct TimedResolvedModules {
1049    project: resolve::ResolvedProject,
1050    elapsed_ms: f64,
1051}
1052
1053struct TimedGraph {
1054    graph: graph::ModuleGraph,
1055    elapsed_ms: f64,
1056}
1057
1058struct GraphCacheHit {
1059    graph: graph::ModuleGraph,
1060    project: resolve::ResolvedProject,
1061    elapsed_ms: f64,
1062}
1063
1064#[derive(Clone, Copy)]
1065struct DiscoverAllEntryPointsInput<'a> {
1066    config: &'a ResolvedConfig,
1067    files: &'a [discover::DiscoveredFile],
1068    workspaces: &'a [fallow_config::WorkspaceInfo],
1069    root_pkg: Option<&'a PackageJson>,
1070    workspace_pkgs: &'a [LoadedWorkspacePackage],
1071    plugin_result: &'a plugins::AggregatedPluginResult,
1072}
1073
1074struct TimedAnalysis {
1075    result: AnalysisResults,
1076    elapsed_ms: f64,
1077}
1078
1079fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
1080    let t = Instant::now();
1081    let entry_points = discover_all_entry_points(DiscoverAllEntryPointsInput {
1082        config: input.config,
1083        files: input.files,
1084        workspaces: input.workspaces,
1085        root_pkg: input.root_pkg,
1086        workspace_pkgs: input.workspace_pkgs,
1087        plugin_result: input.plugin_result,
1088    });
1089    let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
1090    let summary = summarize_entry_points(&entry_points.all);
1091    let count = entry_points.all.len();
1092
1093    TimedEntryPoints {
1094        entry_points,
1095        summary,
1096        count,
1097        elapsed_ms,
1098    }
1099}
1100
1101fn try_load_analysis_graph_cache(
1102    input: &AnalysisCoreSharedInput<'_>,
1103    entry_points: &TimedEntryPoints,
1104    modules: &[extract::ModuleInfo],
1105) -> Option<GraphCacheHit> {
1106    if input.config.no_cache {
1107        return None;
1108    }
1109
1110    let t = Instant::now();
1111    input.progress.set_stage("loading module graph cache...");
1112    let current = build_graph_cache_manifest(
1113        input.config,
1114        input.plugin_result,
1115        &entry_points.entry_points,
1116        input.files,
1117    );
1118    let store = graph_cache::GraphCacheStore::load(&input.config.cache_dir)?;
1119    if store.manifest.matches_inputs(&current) {
1120        let project = graph_cache::restore_resolved_project(
1121            &input.config.root,
1122            modules,
1123            input.files,
1124            &store.resolved_project,
1125        )?;
1126        tracing::debug!("Graph cache hit: skipping import resolution and graph build");
1127
1128        return Some(GraphCacheHit {
1129            graph: store.graph,
1130            project,
1131            elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1132        });
1133    }
1134
1135    if !store.manifest.matches_resolution_inputs(&current) {
1136        return None;
1137    }
1138
1139    let project = graph_cache::restore_resolved_project(
1140        &input.config.root,
1141        modules,
1142        input.files,
1143        &store.resolved_project,
1144    )?;
1145    tracing::debug!("Graph resolver cache hit: skipping import resolution and rebuilding graph");
1146    let graph = build_analysis_graph_timed(input, &project, entry_points, modules);
1147
1148    Some(GraphCacheHit {
1149        graph: graph.graph,
1150        project,
1151        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1152    })
1153}
1154
1155fn resolve_analysis_imports_timed(
1156    input: &AnalysisCoreSharedInput<'_>,
1157    modules: &[extract::ModuleInfo],
1158) -> TimedResolvedModules {
1159    let t = Instant::now();
1160    input.progress.set_stage("resolving imports...");
1161    let project = resolve_analysis_imports(
1162        modules,
1163        input.files,
1164        input.workspaces,
1165        input.plugin_result,
1166        input.config,
1167    );
1168    TimedResolvedModules {
1169        project,
1170        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1171    }
1172}
1173
1174fn build_analysis_graph_timed(
1175    input: &AnalysisCoreSharedInput<'_>,
1176    project: &resolve::ResolvedProject,
1177    entry_points: &TimedEntryPoints,
1178    modules: &[extract::ModuleInfo],
1179) -> TimedGraph {
1180    let t = Instant::now();
1181    input.progress.set_stage("building module graph...");
1182    let graph = build_analysis_graph(&BuildAnalysisGraphInput {
1183        config: input.config,
1184        plugin_result: input.plugin_result,
1185        project,
1186        entry_points: &entry_points.entry_points,
1187        files: input.files,
1188        modules,
1189        workspaces: input.workspaces,
1190    });
1191    TimedGraph {
1192        graph,
1193        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1194    }
1195}
1196
1197fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
1198    for module in modules {
1199        module.release_resolution_payload();
1200    }
1201}
1202
1203fn analyze_dead_code_timed(
1204    input: &AnalysisCoreSharedInput<'_>,
1205    graph: &graph::ModuleGraph,
1206    resolved: &[resolve::ResolvedModule],
1207    modules: &[extract::ModuleInfo],
1208    collect_usages: bool,
1209    entry_point_summary: results::EntryPointSummary,
1210) -> TimedAnalysis {
1211    let t = Instant::now();
1212    input.progress.set_stage("analyzing...");
1213    #[expect(
1214        deprecated,
1215        reason = "Core-internal policy keeps workspace path-dependency calls while warning external fallow-core consumers"
1216    )]
1217    let mut result = analyze::find_dead_code_full(
1218        graph,
1219        input.config,
1220        resolved,
1221        Some(input.plugin_result),
1222        input.workspaces,
1223        modules,
1224        collect_usages,
1225    );
1226    result.entry_point_summary = Some(entry_point_summary);
1227    TimedAnalysis {
1228        result,
1229        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1230    }
1231}
1232
1233fn analyze_full(
1234    config: &ResolvedConfig,
1235    retain: bool,
1236    collect_usages: bool,
1237    need_complexity: bool,
1238    retain_modules: bool,
1239) -> Result<AnalysisOutput, FallowError> {
1240    let _span = tracing::info_span!("fallow_analyze").entered();
1241    AnalysisSession::new(config)?.run_full(retain, collect_usages, need_complexity, retain_modules)
1242}
1243
1244fn full_analysis_pipeline_profile(
1245    timings: &PreludeTimings,
1246    pipeline_start: Instant,
1247    files: &[discover::DiscoveredFile],
1248    workspaces: &[fallow_config::WorkspaceInfo],
1249    core: &OwnedAnalysisCore,
1250    metrics: &ParseMetrics,
1251) -> PipelineProfile {
1252    let prelude = prelude_metrics(
1253        timings,
1254        pipeline_start,
1255        files,
1256        workspaces,
1257        core.modules.len(),
1258    );
1259    full_pipeline_profile(&prelude, core, metrics)
1260}
1261
1262/// Assemble the `AnalysisOutput` for the full pipeline, honoring the graph/module
1263/// retention flags and computing per-file content hashes.
1264fn assemble_full_output(
1265    core: OwnedAnalysisCore,
1266    plugin_result: plugins::AggregatedPluginResult,
1267    profile: &PipelineProfile,
1268    files: &[discover::DiscoveredFile],
1269    retain: bool,
1270    retain_modules: bool,
1271) -> AnalysisOutput {
1272    let file_hashes = collect_file_hashes(&core.modules, files);
1273    AnalysisOutput {
1274        results: core.result,
1275        timings: retained_pipeline_timings(retain, profile),
1276        graph: if retain { Some(core.graph) } else { None },
1277        modules: if retain_modules {
1278            Some(core.modules)
1279        } else {
1280            None
1281        },
1282        files: if retain_modules {
1283            Some(files.to_vec())
1284        } else {
1285            None
1286        },
1287        script_used_packages: plugin_result.script_used_packages,
1288        file_hashes,
1289    }
1290}
1291
1292/// Result of the freshly-parsed analysis core; returns the owned `modules` (so the
1293/// caller can retain them) plus the per-phase timings.
1294struct OwnedAnalysisCore {
1295    result: AnalysisResults,
1296    graph: graph::ModuleGraph,
1297    modules: Vec<extract::ModuleInfo>,
1298    entry_point_count: usize,
1299    entry_points_ms: f64,
1300    resolve_ms: f64,
1301    graph_ms: f64,
1302    analyze_ms: f64,
1303}
1304
1305/// Assemble the `PipelineProfile` for the full (freshly parsed) pipeline path.
1306fn full_pipeline_profile(
1307    prelude: &PreludeMetrics,
1308    core: &OwnedAnalysisCore,
1309    parse: &ParseMetrics,
1310) -> PipelineProfile {
1311    PipelineProfile {
1312        discover_ms: prelude.discover_ms,
1313        workspaces_ms: prelude.workspaces_ms,
1314        plugins_ms: prelude.plugins_ms,
1315        scripts_ms: prelude.scripts_ms,
1316        parse_ms: parse.parse_ms,
1317        cache_ms: parse.cache_ms,
1318        entry_points_ms: core.entry_points_ms,
1319        resolve_ms: core.resolve_ms,
1320        graph_ms: core.graph_ms,
1321        analyze_ms: core.analyze_ms,
1322        total_ms: prelude.total_ms,
1323        file_count: prelude.file_count,
1324        workspace_count: prelude.workspace_count,
1325        module_count: prelude.module_count,
1326        entry_point_count: core.entry_point_count,
1327        cache_hits: parse.cache_hits,
1328        cache_misses: parse.cache_misses,
1329        parse_cpu_ms: parse.parse_cpu_ms,
1330    }
1331}
1332
1333#[derive(Clone, Copy)]
1334struct PipelineProfile {
1335    discover_ms: f64,
1336    workspaces_ms: f64,
1337    plugins_ms: f64,
1338    scripts_ms: f64,
1339    parse_ms: f64,
1340    cache_ms: f64,
1341    entry_points_ms: f64,
1342    resolve_ms: f64,
1343    graph_ms: f64,
1344    analyze_ms: f64,
1345    total_ms: f64,
1346    file_count: usize,
1347    workspace_count: usize,
1348    module_count: usize,
1349    entry_point_count: usize,
1350    cache_hits: usize,
1351    cache_misses: usize,
1352    parse_cpu_ms: f64,
1353}
1354
1355struct AnalysisParseOutput {
1356    modules: Vec<extract::ModuleInfo>,
1357    metrics: ParseMetrics,
1358}
1359
1360/// Parse/cache phase metrics carried into the full-pipeline `PipelineProfile`.
1361struct ParseMetrics {
1362    parse_ms: f64,
1363    cache_ms: f64,
1364    cache_hits: usize,
1365    cache_misses: usize,
1366    parse_cpu_ms: f64,
1367}
1368
1369impl From<AnalysisParseMetrics> for ParseMetrics {
1370    fn from(metrics: AnalysisParseMetrics) -> Self {
1371        Self {
1372            parse_ms: metrics.parse_ms,
1373            cache_ms: metrics.cache_ms,
1374            cache_hits: metrics.cache_hits,
1375            cache_misses: metrics.cache_misses,
1376            parse_cpu_ms: metrics.parse_cpu_ms,
1377        }
1378    }
1379}
1380
1381fn parse_analysis_modules(
1382    config: &ResolvedConfig,
1383    files: &[discover::DiscoveredFile],
1384    need_complexity: bool,
1385    start: Instant,
1386) -> AnalysisParseOutput {
1387    let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1388    let mut cache_store = if config.no_cache {
1389        None
1390    } else {
1391        cache::CacheStore::load(
1392            &config.cache_dir,
1393            config.cache_config_hash,
1394            cache_max_size_bytes,
1395        )
1396    };
1397
1398    let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
1399    let _ = fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
1400    let modules = parse_result.modules;
1401    let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1402    let cache_ms = update_parse_cache_if_enabled(
1403        config,
1404        &mut cache_store,
1405        &modules,
1406        files,
1407        cache_max_size_bytes,
1408    );
1409
1410    AnalysisParseOutput {
1411        modules,
1412        metrics: ParseMetrics {
1413            parse_ms,
1414            cache_ms,
1415            cache_hits: parse_result.cache_hits,
1416            cache_misses: parse_result.cache_misses,
1417            parse_cpu_ms: parse_result.parse_cpu_ms,
1418        },
1419    }
1420}
1421
1422fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1423    retain.then_some(PipelineTimings {
1424        discover_files_ms: profile.discover_ms,
1425        file_count: profile.file_count,
1426        workspaces_ms: profile.workspaces_ms,
1427        workspace_count: profile.workspace_count,
1428        plugins_ms: profile.plugins_ms,
1429        script_analysis_ms: profile.scripts_ms,
1430        parse_extract_ms: profile.parse_ms,
1431        parse_cpu_ms: profile.parse_cpu_ms,
1432        module_count: profile.module_count,
1433        cache_hits: profile.cache_hits,
1434        cache_misses: profile.cache_misses,
1435        cache_update_ms: profile.cache_ms,
1436        entry_points_ms: profile.entry_points_ms,
1437        entry_point_count: profile.entry_point_count,
1438        resolve_imports_ms: profile.resolve_ms,
1439        build_graph_ms: profile.graph_ms,
1440        analyze_ms: profile.analyze_ms,
1441        duplication_ms: None,
1442        total_ms: profile.total_ms,
1443    })
1444}
1445
1446fn update_parse_cache_if_enabled(
1447    config: &ResolvedConfig,
1448    cache_store: &mut Option<cache::CacheStore>,
1449    modules: &[extract::ModuleInfo],
1450    files: &[discover::DiscoveredFile],
1451    cache_max_size_bytes: usize,
1452) -> f64 {
1453    let t = Instant::now();
1454    if !config.no_cache {
1455        let store = cache_store.get_or_insert_with(cache::CacheStore::new);
1456        if update_cache(store, modules, files)
1457            && let Err(error) = store.save(
1458                &config.cache_dir,
1459                config.cache_config_hash,
1460                cache_max_size_bytes,
1461            )
1462        {
1463            tracing::warn!("Failed to save cache: {error}");
1464        }
1465    }
1466    t.elapsed().as_secs_f64() * 1000.0
1467}
1468
1469fn resolve_analysis_imports(
1470    modules: &[extract::ModuleInfo],
1471    files: &[discover::DiscoveredFile],
1472    workspaces: &[fallow_config::WorkspaceInfo],
1473    plugin_result: &plugins::AggregatedPluginResult,
1474    config: &ResolvedConfig,
1475) -> resolve::ResolvedProject {
1476    let mut project = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1477        modules,
1478        files,
1479        workspaces,
1480        active_plugins: &plugin_result.active_plugins,
1481        path_aliases: &plugin_result.path_aliases,
1482        auto_imports: &plugin_result.auto_imports,
1483        scss_include_paths: &plugin_result.scss_include_paths,
1484        static_dir_mappings: &plugin_result.static_dir_mappings,
1485        root: &config.root,
1486        extra_conditions: &config.resolve.conditions,
1487    });
1488    external_style_usage::augment_external_style_package_usage(
1489        &mut project.modules,
1490        config,
1491        workspaces,
1492        plugin_result,
1493    );
1494    project
1495}
1496
1497struct BuildAnalysisGraphInput<'a> {
1498    config: &'a ResolvedConfig,
1499    plugin_result: &'a plugins::AggregatedPluginResult,
1500    project: &'a resolve::ResolvedProject,
1501    entry_points: &'a discover::CategorizedEntryPoints,
1502    files: &'a [discover::DiscoveredFile],
1503    modules: &'a [extract::ModuleInfo],
1504    workspaces: &'a [fallow_config::WorkspaceInfo],
1505}
1506
1507/// Build the analysis graph and persist it for the next identical run.
1508///
1509/// The warm hit path happens before import resolution in
1510/// `try_load_analysis_graph_cache`. This miss path always builds fresh, runs
1511/// both credit steps, and persists the graph plus resolver outputs for next
1512/// time. The cache is gated on `config.no_cache` and is a strict performance
1513/// optimization: a cache hit produces identical analysis results.
1514fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
1515    let caching_enabled = !input.config.no_cache;
1516    let current_manifest = caching_enabled.then(|| {
1517        build_graph_cache_manifest(
1518            input.config,
1519            input.plugin_result,
1520            input.entry_points,
1521            input.files,
1522        )
1523    });
1524
1525    let mut graph = graph::ModuleGraph::build_with_reachability_roots_and_replacements(
1526        &input.project.modules,
1527        &input.project.replaced_module_targets,
1528        &input.entry_points.all,
1529        &input.entry_points.runtime,
1530        &input.entry_points.test,
1531        input.files,
1532    );
1533    credit_package_path_references(&mut graph, input.modules);
1534    credit_workspace_package_usage(&mut graph, &input.project.modules, input.workspaces);
1535
1536    if let Some(manifest) = current_manifest {
1537        let Some(resolved_project) =
1538            graph_cache::cache_resolved_project(&input.config.root, input.files, input.project)
1539        else {
1540            return graph;
1541        };
1542        let store = graph_cache::GraphCacheStore {
1543            version: graph_cache::GRAPH_CACHE_VERSION,
1544            manifest,
1545            graph,
1546            resolved_project,
1547        };
1548        store.save(&input.config.cache_dir);
1549        // `save` borrows the store, so the freshly built graph is moved back out
1550        // and returned in-memory. The warm path loads-and-reconstructs an
1551        // identical graph from this same persisted blob (proven by the
1552        // cold-vs-warm correctness gate).
1553        return store.graph;
1554    }
1555
1556    graph
1557}
1558
1559/// Build the current `GraphCacheManifest` from the run's discovered files and
1560/// graph-affecting option hashes.
1561fn build_graph_cache_manifest(
1562    config: &ResolvedConfig,
1563    plugin_result: &plugins::AggregatedPluginResult,
1564    entry_points: &discover::CategorizedEntryPoints,
1565    files: &[discover::DiscoveredFile],
1566) -> graph_cache::GraphCacheManifest {
1567    let mode = graph_cache::GraphCacheMode::new(
1568        resolver_options_hash(config),
1569        entry_points_hash(entry_points),
1570        plugin_config_hash(plugin_result),
1571    );
1572    graph_cache::GraphCacheManifest::from_discovered_files(&config.root, files, mode, |path| {
1573        std::fs::metadata(path).map_or(
1574            fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
1575            |metadata| {
1576                fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata)
1577            },
1578        )
1579    })
1580}
1581
1582/// Hash the resolver-affecting options: the project root, extraction config
1583/// hash (which already folds tsconfig / resolver-relevant config), and the
1584/// user-supplied resolve `conditions`.
1585///
1586/// `production` and `ignore_patterns` intentionally stay out of this hash:
1587/// they shape discovery, so changed file sets already miss through stable file
1588/// keys and source fingerprints in the manifest.
1589fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
1590    use std::hash::{Hash, Hasher};
1591    let mut hasher = rustc_hash::FxHasher::default();
1592    config.root.hash(&mut hasher);
1593    config.cache_config_hash.hash(&mut hasher);
1594    config.resolve.conditions.hash(&mut hasher);
1595    hasher.finish()
1596}
1597
1598/// Hash the entry-point set (sorted paths per role) so any change in reachability
1599/// roots misses the cache.
1600fn entry_points_hash(entry_points: &discover::CategorizedEntryPoints) -> u64 {
1601    use std::hash::{Hash, Hasher};
1602    let mut hasher = rustc_hash::FxHasher::default();
1603    for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
1604        let mut paths: Vec<&std::path::Path> = role.iter().map(|ep| ep.path.as_path()).collect();
1605        paths.sort_unstable();
1606        paths.len().hash(&mut hasher);
1607        for path in paths {
1608            path.hash(&mut hasher);
1609        }
1610    }
1611    hasher.finish()
1612}
1613
1614/// Hash the plugin-derived graph-affecting configuration.
1615fn plugin_config_hash(plugin_result: &plugins::AggregatedPluginResult) -> u64 {
1616    use std::hash::{Hash, Hasher};
1617    let mut hasher = rustc_hash::FxHasher::default();
1618
1619    hash_active_plugins(plugin_result, &mut hasher);
1620    hash_path_aliases(plugin_result, &mut hasher);
1621
1622    let mut auto_imports: Vec<(&str, &std::path::Path, fallow_config::AutoImportKind)> =
1623        plugin_result
1624            .auto_imports
1625            .iter()
1626            .map(|rule| (rule.name.as_str(), rule.source.as_path(), rule.kind))
1627            .collect();
1628    auto_imports.sort_unstable_by(|a, b| {
1629        a.0.cmp(b.0)
1630            .then_with(|| a.1.cmp(b.1))
1631            .then_with(|| auto_import_kind_rank(a.2).cmp(&auto_import_kind_rank(b.2)))
1632    });
1633    auto_imports.len().hash(&mut hasher);
1634    for (name, source, kind) in auto_imports {
1635        name.hash(&mut hasher);
1636        source.hash(&mut hasher);
1637        auto_import_kind_rank(kind).hash(&mut hasher);
1638    }
1639
1640    let mut scss_include_paths: Vec<&std::path::Path> = plugin_result
1641        .scss_include_paths
1642        .iter()
1643        .map(std::path::PathBuf::as_path)
1644        .collect();
1645    scss_include_paths.sort_unstable();
1646    scss_include_paths.len().hash(&mut hasher);
1647    for path in scss_include_paths {
1648        path.hash(&mut hasher);
1649    }
1650
1651    let mut static_dir_mappings: Vec<(&std::path::Path, &str)> = plugin_result
1652        .static_dir_mappings
1653        .iter()
1654        .map(|(from_dir, mount)| (from_dir.as_path(), mount.as_str()))
1655        .collect();
1656    static_dir_mappings.sort_unstable();
1657    static_dir_mappings.len().hash(&mut hasher);
1658    for (from_dir, mount) in static_dir_mappings {
1659        from_dir.hash(&mut hasher);
1660        mount.hash(&mut hasher);
1661    }
1662
1663    hasher.finish()
1664}
1665
1666fn hash_active_plugins(
1667    plugin_result: &plugins::AggregatedPluginResult,
1668    hasher: &mut rustc_hash::FxHasher,
1669) {
1670    use std::hash::Hash;
1671    let mut active: Vec<&str> = plugin_result
1672        .active_plugins
1673        .iter()
1674        .map(String::as_str)
1675        .collect();
1676    active.sort_unstable();
1677    active.len().hash(hasher);
1678    for name in active {
1679        name.hash(hasher);
1680    }
1681}
1682
1683fn hash_path_aliases(
1684    plugin_result: &plugins::AggregatedPluginResult,
1685    hasher: &mut rustc_hash::FxHasher,
1686) {
1687    use std::hash::Hash;
1688    let mut aliases: Vec<(&str, &str)> = plugin_result
1689        .path_aliases
1690        .iter()
1691        .map(|(prefix, replacement)| (prefix.as_str(), replacement.as_str()))
1692        .collect();
1693    aliases.sort_unstable();
1694    aliases.len().hash(hasher);
1695    for (prefix, replacement) in aliases {
1696        prefix.hash(hasher);
1697        replacement.hash(hasher);
1698    }
1699}
1700
1701fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
1702    match kind {
1703        fallow_config::AutoImportKind::Named => 0,
1704        fallow_config::AutoImportKind::Default => 1,
1705        fallow_config::AutoImportKind::DefaultComponent => 2,
1706    }
1707}
1708
1709fn collect_file_hashes(
1710    modules: &[extract::ModuleInfo],
1711    files: &[discover::DiscoveredFile],
1712) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1713    modules
1714        .iter()
1715        .filter_map(|module| {
1716            files
1717                .get(module.file_id.0 as usize)
1718                .map(|file| (file.path.clone(), module.content_hash))
1719        })
1720        .collect()
1721}
1722
1723fn trace_pipeline_profile(profile: &PipelineProfile) {
1724    let PipelineProfile {
1725        discover_ms,
1726        workspaces_ms,
1727        plugins_ms,
1728        scripts_ms,
1729        parse_ms,
1730        cache_ms,
1731        entry_points_ms,
1732        resolve_ms,
1733        graph_ms,
1734        analyze_ms,
1735        total_ms,
1736        file_count,
1737        module_count,
1738        entry_point_count,
1739        cache_hits,
1740        cache_misses,
1741        ..
1742    } = *profile;
1743    let cache_summary = if cache_hits > 0 {
1744        format!(" ({cache_hits} cached, {cache_misses} parsed)")
1745    } else {
1746        String::new()
1747    };
1748
1749    tracing::debug!(
1750        "\n┌─ Pipeline Profile ─────────────────────────────\n\
1751         │  discover files:   {:>8.1}ms  ({} files)\n\
1752         │  workspaces:       {:>8.1}ms\n\
1753         │  plugins:          {:>8.1}ms\n\
1754         │  script analysis:  {:>8.1}ms\n\
1755         │  parse/extract:    {:>8.1}ms  ({} modules{})\n\
1756         │  cache update:     {:>8.1}ms\n\
1757         │  entry points:     {:>8.1}ms  ({} entries)\n\
1758         │  resolve imports:  {:>8.1}ms\n\
1759         │  build graph:      {:>8.1}ms\n\
1760         │  analyze:          {:>8.1}ms\n\
1761         │  ────────────────────────────────────────────\n\
1762         │  TOTAL:            {:>8.1}ms\n\
1763         └─────────────────────────────────────────────────",
1764        discover_ms,
1765        file_count,
1766        workspaces_ms,
1767        plugins_ms,
1768        scripts_ms,
1769        parse_ms,
1770        module_count,
1771        cache_summary,
1772        cache_ms,
1773        entry_points_ms,
1774        entry_point_count,
1775        resolve_ms,
1776        graph_ms,
1777        analyze_ms,
1778        total_ms,
1779    );
1780}
1781
1782/// Analyze package.json scripts from root and all workspace packages.
1783///
1784/// Populates the plugin result with script-used packages and config file
1785/// entry patterns. Also scans CI config files for binary invocations.
1786fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
1787    fallow_config::load_dir_package_json(&config.root)
1788}
1789
1790fn load_workspace_packages(
1791    workspaces: &[fallow_config::WorkspaceInfo],
1792) -> Vec<LoadedWorkspacePackage> {
1793    workspaces
1794        .iter()
1795        .filter_map(|ws| {
1796            fallow_config::load_dir_package_json(&ws.root).map(|pkg| (ws.clone(), pkg))
1797        })
1798        .collect()
1799}
1800
1801fn analyze_all_scripts(
1802    config: &ResolvedConfig,
1803    workspaces: &[fallow_config::WorkspaceInfo],
1804    root_pkg: Option<&PackageJson>,
1805    workspace_pkgs: &[LoadedWorkspacePackage],
1806    plugin_result: &mut plugins::AggregatedPluginResult,
1807) {
1808    let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
1809    let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
1810    let all_scripts = collect_all_scripts(root_pkg, workspace_pkgs);
1811
1812    let nm_roots = collect_node_modules_roots(config, workspaces);
1813    let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
1814
1815    analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
1816    analyze_workspace_scripts(
1817        config,
1818        workspace_pkgs,
1819        &bin_map,
1820        &all_dep_set,
1821        plugin_result,
1822    );
1823    analyze_ci_scripts(config, &bin_map, &all_dep_set, &all_scripts, plugin_result);
1824
1825    plugin_result
1826        .entry_point_roles
1827        .entry("scripts".to_string())
1828        .or_insert(EntryPointRole::Support);
1829}
1830
1831/// Gather sorted, deduped dependency names across the root and workspace packages.
1832fn collect_all_dependency_names(
1833    root_pkg: Option<&PackageJson>,
1834    workspace_pkgs: &[LoadedWorkspacePackage],
1835) -> Vec<String> {
1836    let mut all_dep_names: Vec<String> = Vec::new();
1837    if let Some(pkg) = root_pkg {
1838        all_dep_names.extend(pkg.all_dependency_names());
1839    }
1840    for (_, ws_pkg) in workspace_pkgs {
1841        all_dep_names.extend(ws_pkg.all_dependency_names());
1842    }
1843    all_dep_names.sort_unstable();
1844    all_dep_names.dedup();
1845    all_dep_names
1846}
1847
1848/// Gather the scripts declared by the root and workspace packages.
1849fn collect_all_scripts(
1850    root_pkg: Option<&PackageJson>,
1851    workspace_pkgs: &[LoadedWorkspacePackage],
1852) -> scripts::ScriptCatalog {
1853    let mut catalog = scripts::ScriptCatalog::default();
1854    if let Some(pkg) = root_pkg
1855        && let Some(ref pkg_scripts) = pkg.scripts
1856    {
1857        catalog.merge_scripts(pkg_scripts);
1858    }
1859    for (_, ws_pkg) in workspace_pkgs {
1860        if let Some(ref ws_scripts) = ws_pkg.scripts {
1861            catalog.merge_workspace_scripts(ws_scripts);
1862        }
1863    }
1864    catalog
1865}
1866
1867/// Collect every directory (root and workspaces) that has a local `node_modules`.
1868fn collect_node_modules_roots<'a>(
1869    config: &'a ResolvedConfig,
1870    workspaces: &'a [fallow_config::WorkspaceInfo],
1871) -> Vec<&'a std::path::Path> {
1872    let mut nm_roots: Vec<&std::path::Path> = Vec::new();
1873    if config.root.join("node_modules").is_dir() {
1874        nm_roots.push(&config.root);
1875    }
1876    for ws in workspaces {
1877        if ws.root.join("node_modules").is_dir() {
1878            nm_roots.push(&ws.root);
1879        }
1880    }
1881    nm_roots
1882}
1883
1884/// Analyze the root package.json scripts and fold the results into the plugin result.
1885fn analyze_root_scripts(
1886    config: &ResolvedConfig,
1887    root_pkg: Option<&PackageJson>,
1888    bin_map: &rustc_hash::FxHashMap<String, String>,
1889    all_dep_set: &FxHashSet<String>,
1890    plugin_result: &mut plugins::AggregatedPluginResult,
1891) {
1892    let Some(pkg) = root_pkg else {
1893        return;
1894    };
1895    let Some(ref pkg_scripts) = pkg.scripts else {
1896        return;
1897    };
1898    let scripts_to_analyze = if config.production {
1899        scripts::filter_production_scripts(pkg_scripts)
1900    } else {
1901        pkg_scripts.clone()
1902    };
1903    let catalog =
1904        scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1905    let script_analysis = scripts::analyze_scripts_with_dependency_context(
1906        &scripts_to_analyze,
1907        &config.root,
1908        bin_map,
1909        all_dep_set,
1910        &catalog,
1911    );
1912    plugin_result.script_used_packages = script_analysis.used_packages;
1913
1914    for config_file in &script_analysis.config_files {
1915        plugin_result
1916            .discovered_always_used
1917            .push((config_file.clone(), "scripts".to_string()));
1918    }
1919    for entry in &script_analysis.entry_files {
1920        if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1921            plugin_result
1922                .entry_patterns
1923                .push((plugins::PathRule::new(pat), "scripts".to_string()));
1924        }
1925    }
1926}
1927
1928/// Analyze each workspace package's scripts in parallel and merge the results.
1929type WsScriptOut = (
1930    Vec<String>,
1931    Vec<(String, String)>,
1932    Vec<(plugins::PathRule, String)>,
1933);
1934
1935fn analyze_workspace_scripts(
1936    config: &ResolvedConfig,
1937    workspace_pkgs: &[LoadedWorkspacePackage],
1938    bin_map: &rustc_hash::FxHashMap<String, String>,
1939    all_dep_set: &FxHashSet<String>,
1940    plugin_result: &mut plugins::AggregatedPluginResult,
1941) {
1942    let ws_results: Vec<WsScriptOut> = workspace_pkgs
1943        .par_iter()
1944        .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
1945        .collect();
1946    for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1947        plugin_result.script_used_packages.extend(used_packages);
1948        plugin_result
1949            .discovered_always_used
1950            .extend(discovered_always_used);
1951        plugin_result.entry_patterns.extend(entry_patterns);
1952    }
1953}
1954
1955/// Analyze a single workspace package's scripts, returning its used packages,
1956/// always-used config files, and entry patterns (all workspace-prefixed).
1957fn analyze_one_workspace_scripts(
1958    config: &ResolvedConfig,
1959    ws: &fallow_config::WorkspaceInfo,
1960    ws_pkg: &PackageJson,
1961    bin_map: &rustc_hash::FxHashMap<String, String>,
1962    all_dep_set: &FxHashSet<String>,
1963) -> WsScriptOut {
1964    let mut used_packages = Vec::new();
1965    let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1966    let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1967    let Some(ref ws_scripts) = ws_pkg.scripts else {
1968        return (used_packages, discovered_always_used, entry_patterns);
1969    };
1970    let scripts_to_analyze = if config.production {
1971        scripts::filter_production_scripts(ws_scripts)
1972    } else {
1973        ws_scripts.clone()
1974    };
1975    let catalog = scripts::ScriptCatalog::from_scripts_with_bodies(ws_scripts, &scripts_to_analyze);
1976    let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1977        &scripts_to_analyze,
1978        &ws.root,
1979        bin_map,
1980        all_dep_set,
1981        &catalog,
1982    );
1983    used_packages.extend(ws_analysis.used_packages);
1984
1985    let ws_prefix = ws
1986        .root
1987        .strip_prefix(&config.root)
1988        .unwrap_or(&ws.root)
1989        .to_string_lossy();
1990    for config_file in &ws_analysis.config_files {
1991        discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1992    }
1993    for entry in &ws_analysis.entry_files {
1994        if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1995            entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1996        }
1997    }
1998    (used_packages, discovered_always_used, entry_patterns)
1999}
2000
2001/// Analyze CI config files for binary invocations and merge the results.
2002fn analyze_ci_scripts(
2003    config: &ResolvedConfig,
2004    bin_map: &rustc_hash::FxHashMap<String, String>,
2005    all_dep_set: &FxHashSet<String>,
2006    all_scripts: &scripts::ScriptCatalog,
2007    plugin_result: &mut plugins::AggregatedPluginResult,
2008) {
2009    let ci_analysis =
2010        scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_scripts);
2011    plugin_result
2012        .script_used_packages
2013        .extend(ci_analysis.used_packages);
2014    for entry in &ci_analysis.entry_files {
2015        if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2016            plugin_result
2017                .entry_patterns
2018                .push((plugins::PathRule::new(pat), "scripts".to_string()));
2019        }
2020    }
2021}
2022
2023/// Discover all entry points from static patterns, workspaces, plugins, and infrastructure.
2024fn discover_all_entry_points(
2025    input: DiscoverAllEntryPointsInput<'_>,
2026) -> discover::CategorizedEntryPoints {
2027    let mut entry_points = discover::CategorizedEntryPoints::default();
2028    let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
2029        input.config,
2030        input.files,
2031        input.root_pkg,
2032        input.workspaces.is_empty(),
2033    );
2034
2035    let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> = input
2036        .workspace_pkgs
2037        .iter()
2038        .map(|(ws, pkg)| (ws.root.clone(), pkg))
2039        .collect();
2040    let workspace_script_seeds = discover::workspace_runtime_script_seeds(
2041        &input.config.root,
2042        input.root_pkg,
2043        input.workspace_pkgs,
2044    );
2045
2046    let workspace_discovery: Vec<discover::EntryPointDiscovery> = input
2047        .workspaces
2048        .par_iter()
2049        .map(|ws| {
2050            let pkg = workspace_pkg_by_root.get(&ws.root).copied();
2051            let seeds = workspace_script_seeds
2052                .get(&ws.name)
2053                .cloned()
2054                .unwrap_or_default();
2055            discover::discover_workspace_entry_points_with_runtime_scripts(
2056                &ws.root,
2057                input.files,
2058                pkg,
2059                &seeds,
2060            )
2061        })
2062        .collect();
2063    let mut skipped_entries = rustc_hash::FxHashMap::default();
2064    entry_points.extend_runtime(root_discovery.entries);
2065    entry_points.extend_support(root_discovery.support_entries);
2066    for (path, count) in root_discovery.skipped_entries {
2067        *skipped_entries.entry(path).or_insert(0) += count;
2068    }
2069    let mut ws_entries = Vec::new();
2070    let mut ws_support_entries = Vec::new();
2071    for workspace in workspace_discovery {
2072        ws_entries.extend(workspace.entries);
2073        ws_support_entries.extend(workspace.support_entries);
2074        for (path, count) in workspace.skipped_entries {
2075            *skipped_entries.entry(path).or_insert(0) += count;
2076        }
2077    }
2078    discover::warn_skipped_entry_summary(&skipped_entries);
2079    entry_points.extend_runtime(ws_entries);
2080    entry_points.extend_support(ws_support_entries);
2081
2082    let plugin_entries =
2083        discover::discover_plugin_entry_point_sets(input.plugin_result, input.config, input.files);
2084    entry_points.extend(plugin_entries);
2085
2086    let infra_entries = discover::discover_infrastructure_entry_points(&input.config.root);
2087    entry_points.extend_runtime(infra_entries);
2088
2089    if !input.config.dynamically_loaded.is_empty() {
2090        let dynamic_entries =
2091            discover::discover_dynamically_loaded_entry_points(input.config, input.files);
2092        entry_points.extend_runtime(dynamic_entries);
2093    }
2094
2095    entry_points.dedup()
2096}
2097
2098/// Summarize entry points by source category for user-facing output.
2099fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
2100    let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
2101    for ep in entry_points {
2102        let category = match &ep.source {
2103            discover::EntryPointSource::PackageJsonMain
2104            | discover::EntryPointSource::PackageJsonModule
2105            | discover::EntryPointSource::PackageJsonExports
2106            | discover::EntryPointSource::PackageJsonBin
2107            | discover::EntryPointSource::PackageJsonScript => "package.json",
2108            discover::EntryPointSource::Plugin { .. } => "plugin",
2109            discover::EntryPointSource::TestFile => "test file",
2110            discover::EntryPointSource::DefaultIndex => "default index",
2111            discover::EntryPointSource::ManualEntry => "manual entry",
2112            discover::EntryPointSource::InfrastructureConfig => "config",
2113            discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
2114        };
2115        *counts.entry(category.to_string()).or_insert(0) += 1;
2116    }
2117    let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
2118    by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2119    results::EntryPointSummary {
2120        total: entry_points.len(),
2121        by_source,
2122    }
2123}
2124
2125fn append_package_file_asset_patterns(
2126    result: &mut plugins::AggregatedPluginResult,
2127    prefix: &str,
2128    pkg: &PackageJson,
2129) {
2130    let prefix = prefix.trim_matches('/');
2131    for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
2132        let pattern = if prefix.is_empty() {
2133            pattern
2134        } else {
2135            format!("{prefix}/{pattern}")
2136        };
2137        result
2138            .discovered_always_used
2139            .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
2140    }
2141}
2142
2143fn append_workspace_package_file_asset_patterns(
2144    result: &mut plugins::AggregatedPluginResult,
2145    config: &ResolvedConfig,
2146    workspace_pkgs: &[LoadedWorkspacePackage],
2147) {
2148    for (ws, ws_pkg) in workspace_pkgs {
2149        let ws_prefix = ws
2150            .root
2151            .strip_prefix(&config.root)
2152            .unwrap_or(&ws.root)
2153            .to_string_lossy()
2154            .replace('\\', "/");
2155        append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
2156    }
2157}
2158
2159/// Run plugins for root project and all workspace packages.
2160fn run_plugins(
2161    config: &ResolvedConfig,
2162    files: &[discover::DiscoveredFile],
2163    workspaces: &[fallow_config::WorkspaceInfo],
2164    root_pkg: Option<&PackageJson>,
2165    workspace_pkgs: &[LoadedWorkspacePackage],
2166    config_candidates: &[std::path::PathBuf],
2167) -> Result<plugins::AggregatedPluginResult, FallowError> {
2168    let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2169    let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
2170
2171    // The non-production config-discovery fast path: resolve plugin config
2172    // patterns against the files the discovery walk already collected (source
2173    // files unioned with non-source config candidates) instead of re-walking the
2174    // filesystem. Production keeps the filesystem path (no candidates captured).
2175    let candidate_index = (!config.production).then(|| {
2176        plugins::registry::ConfigCandidateIndex::build(
2177            file_paths
2178                .iter()
2179                .map(std::path::PathBuf::as_path)
2180                .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
2181        )
2182    });
2183
2184    let mut result = run_root_plugins(
2185        &registry,
2186        config,
2187        root_pkg,
2188        &file_paths,
2189        candidate_index.as_ref(),
2190    )?;
2191
2192    if workspaces.is_empty() {
2193        gate_auto_import_entry_patterns(&mut result, config, workspaces);
2194        return Ok(result);
2195    }
2196
2197    append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
2198
2199    let ws_results = run_workspace_plugins(
2200        &registry,
2201        config,
2202        workspace_pkgs,
2203        &file_paths,
2204        &result.active_plugins,
2205        candidate_index.as_ref(),
2206    );
2207    merge_workspace_plugin_results(&mut result, ws_results)?;
2208
2209    gate_auto_import_entry_patterns(&mut result, config, workspaces);
2210
2211    Ok(result)
2212}
2213
2214type WorkspacePluginResult = Result<
2215    (plugins::AggregatedPluginResult, String),
2216    Vec<plugins::registry::PluginRegexValidationError>,
2217>;
2218
2219/// Run plugins for the root project and apply its package-file asset patterns.
2220fn run_root_plugins(
2221    registry: &plugins::PluginRegistry,
2222    config: &ResolvedConfig,
2223    root_pkg: Option<&PackageJson>,
2224    file_paths: &[std::path::PathBuf],
2225    candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2226) -> Result<plugins::AggregatedPluginResult, FallowError> {
2227    let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
2228    let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
2229        .iter()
2230        .map(std::path::PathBuf::as_path)
2231        .collect();
2232
2233    let mut result = if let Some(pkg) = root_pkg {
2234        registry
2235            .try_run_with_search_roots(
2236                pkg,
2237                &config.root,
2238                file_paths,
2239                &root_config_search_root_refs,
2240                config.production,
2241                candidate_index,
2242            )
2243            .map_err(|errors| {
2244                FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
2245            })?
2246    } else {
2247        plugins::AggregatedPluginResult::default()
2248    };
2249    if let Some(pkg) = root_pkg {
2250        append_package_file_asset_patterns(&mut result, "", pkg);
2251    }
2252    Ok(result)
2253}
2254
2255/// Run plugins for every workspace package in parallel, returning per-workspace
2256/// results (or regex errors) for the caller to merge.
2257fn run_workspace_plugins(
2258    registry: &plugins::PluginRegistry,
2259    config: &ResolvedConfig,
2260    workspace_pkgs: &[LoadedWorkspacePackage],
2261    file_paths: &[std::path::PathBuf],
2262    root_active_plugins: &[String],
2263    candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2264) -> Vec<WorkspacePluginResult> {
2265    let root_active_plugins: rustc_hash::FxHashSet<&str> =
2266        root_active_plugins.iter().map(String::as_str).collect();
2267
2268    let precompiled_matchers = registry.precompile_config_matchers();
2269    let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
2270
2271    workspace_pkgs
2272        .par_iter()
2273        .zip(workspace_relative_files.par_iter())
2274        .filter_map(|((ws, ws_pkg), relative_files)| {
2275            let ws_result =
2276                match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
2277                    pkg: ws_pkg,
2278                    root: &ws.root,
2279                    project_root: &config.root,
2280                    precompiled_config_matchers: &precompiled_matchers,
2281                    relative_files,
2282                    skip_config_plugins: &root_active_plugins,
2283                    production_mode: config.production,
2284                    candidate_index,
2285                }) {
2286                    Ok(result) => result,
2287                    Err(errors) => return Some(Err(errors)),
2288                };
2289            if ws_result.active_plugins.is_empty() {
2290                return None;
2291            }
2292            let ws_prefix = ws
2293                .root
2294                .strip_prefix(&config.root)
2295                .unwrap_or(&ws.root)
2296                .to_string_lossy()
2297                .into_owned();
2298            Some(Ok((ws_result, ws_prefix)))
2299        })
2300        .collect::<Vec<_>>()
2301}
2302
2303/// Merge per-workspace plugin results into the root result, surfacing any
2304/// accumulated regex errors as a single config error.
2305fn merge_workspace_plugin_results(
2306    result: &mut plugins::AggregatedPluginResult,
2307    ws_results: Vec<WorkspacePluginResult>,
2308) -> Result<(), FallowError> {
2309    let mut regex_errors = Vec::new();
2310    for ws_result in ws_results {
2311        match ws_result {
2312            Ok((mut ws_result, ws_prefix)) => {
2313                ws_result.apply_workspace_prefix(&ws_prefix);
2314                ws_result.config_patterns.clear();
2315                ws_result.script_used_packages.clear();
2316                result.merge_into(ws_result);
2317            }
2318            Err(mut errors) => regex_errors.append(&mut errors),
2319        }
2320    }
2321    if !regex_errors.is_empty() {
2322        return Err(FallowError::config(
2323            plugins::registry::format_plugin_regex_errors(&regex_errors),
2324        ));
2325    }
2326    Ok(())
2327}
2328
2329/// When `autoImports` is enabled, drop the modeled Nuxt convention entry
2330/// patterns so genuinely-unreferenced convention files are reported as
2331/// `unused-file`. Component and script fallbacks have separate conservative
2332/// config guards because custom `components:` and `imports:` settings affect
2333/// different convention surfaces.
2334fn gate_auto_import_entry_patterns(
2335    result: &mut plugins::AggregatedPluginResult,
2336    config: &ResolvedConfig,
2337    workspaces: &[fallow_config::WorkspaceInfo],
2338) {
2339    if !config.auto_imports {
2340        return;
2341    }
2342    if !result.active_plugins.iter().any(|name| name == "nuxt") {
2343        return;
2344    }
2345    let components_custom = plugins::nuxt::config_declares_components(&config.root)
2346        || workspaces
2347            .iter()
2348            .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
2349    let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
2350        || workspaces
2351            .iter()
2352            .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
2353    result.entry_patterns.retain(|(rule, plugin)| {
2354        if plugin != "nuxt" {
2355            return true;
2356        }
2357        if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2358            return false;
2359        }
2360        if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2361            return false;
2362        }
2363        true
2364    });
2365}
2366
2367fn bucket_files_by_workspace(
2368    workspace_pkgs: &[LoadedWorkspacePackage],
2369    file_paths: &[std::path::PathBuf],
2370) -> Vec<Vec<(std::path::PathBuf, String)>> {
2371    let workspace_roots: Vec<_> = workspace_pkgs
2372        .iter()
2373        .map(|(workspace, _)| workspace.root.as_path())
2374        .collect();
2375    bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2376}
2377
2378fn bucket_files_by_workspace_roots(
2379    workspace_roots: &[&Path],
2380    file_paths: &[std::path::PathBuf],
2381) -> Vec<Vec<(std::path::PathBuf, String)>> {
2382    use rayon::prelude::*;
2383
2384    // A file may match nested or duplicate workspace roots. Keep the original
2385    // first-declaration-wins contract by storing the first index for each root
2386    // and selecting the lowest index among the file's matching ancestors.
2387    let mut workspace_by_root: rustc_hash::FxHashMap<&Path, usize> =
2388        rustc_hash::FxHashMap::default();
2389    for (idx, root) in workspace_roots.iter().enumerate() {
2390        workspace_by_root.entry(root).or_insert(idx);
2391    }
2392
2393    let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2394        .par_iter()
2395        .map(|file_path| {
2396            let idx = file_path
2397                .ancestors()
2398                .filter_map(|ancestor| workspace_by_root.get(ancestor).copied())
2399                .min()?;
2400            let relative = file_path.strip_prefix(workspace_roots[idx]).ok()?;
2401            Some((
2402                idx,
2403                file_path.clone(),
2404                relative.to_string_lossy().into_owned(),
2405            ))
2406        })
2407        .collect();
2408
2409    let mut buckets = vec![Vec::new(); workspace_roots.len()];
2410    for (idx, file_path, relative) in assignments.into_iter().flatten() {
2411        buckets[idx].push((file_path, relative));
2412    }
2413
2414    buckets
2415}
2416
2417/// Benchmark hook for workspace file assignment. This is not a supported API.
2418#[doc(hidden)]
2419pub fn benchmark_bucket_files_by_workspace(
2420    workspace_roots: &[std::path::PathBuf],
2421    file_paths: &[std::path::PathBuf],
2422) -> Vec<Vec<(std::path::PathBuf, String)>> {
2423    let workspace_roots: Vec<_> = workspace_roots
2424        .iter()
2425        .map(std::path::PathBuf::as_path)
2426        .collect();
2427    bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2428}
2429
2430fn collect_config_search_roots(
2431    root: &Path,
2432    file_paths: &[std::path::PathBuf],
2433) -> Vec<std::path::PathBuf> {
2434    let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2435    roots.insert(root.to_path_buf());
2436
2437    for file_path in file_paths {
2438        let mut current = file_path.parent();
2439        while let Some(dir) = current {
2440            if !dir.starts_with(root) {
2441                break;
2442            }
2443            roots.insert(dir.to_path_buf());
2444            if dir == root {
2445                break;
2446            }
2447            current = dir.parent();
2448        }
2449    }
2450
2451    let mut roots_vec: Vec<_> = roots.into_iter().collect();
2452    roots_vec.sort();
2453    roots_vec
2454}
2455
2456/// Resolve the analysis config for a project, mirroring the CLI's `--config`
2457/// behavior when `config_path` is provided.
2458///
2459/// # Errors
2460///
2461/// Returns an error when an explicit config cannot be loaded or automatic
2462/// config discovery finds an invalid config.
2463fn config_for_project(
2464    root: &Path,
2465    config_path: Option<&Path>,
2466) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2467    let user_config = if let Some(path) = config_path {
2468        Some((
2469            fallow_config::FallowConfig::load(path)
2470                .map_err(|e| FallowError::config(format!("{e:#}")))?,
2471            path.to_path_buf(),
2472        ))
2473    } else {
2474        fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2475    };
2476
2477    let config = match user_config {
2478        Some((config, path)) => resolve_user_config(config, path, root)?,
2479        None => (
2480            fallow_config::FallowConfig::default().resolve(
2481                root.to_path_buf(),
2482                fallow_config::OutputFormat::Human,
2483                num_cpus(),
2484                false,
2485                true,
2486                None,
2487            ),
2488            None,
2489        ),
2490    };
2491
2492    Ok(config)
2493}
2494
2495/// Flatten the dead-code production flag, validate boundaries and rule packs,
2496/// then resolve a user-supplied config for LSP/programmatic callers.
2497fn resolve_user_config(
2498    mut config: fallow_config::FallowConfig,
2499    path: std::path::PathBuf,
2500    root: &Path,
2501) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2502    let dead_code_production = config
2503        .production
2504        .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2505    config.production = dead_code_production.into();
2506    config
2507        .validate_resolved_boundaries(root)
2508        .map_err(|errors| {
2509            let joined = errors
2510                .iter()
2511                .map(ToString::to_string)
2512                .collect::<Vec<_>>()
2513                .join("\n  - ");
2514            FallowError::config(format!("invalid boundary configuration:\n  - {joined}"))
2515        })?;
2516    let packs = fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
2517        let joined = errors
2518            .iter()
2519            .map(ToString::to_string)
2520            .collect::<Vec<_>>()
2521            .join("\n  - ");
2522        FallowError::config(format!("invalid rule pack:\n  - {joined}"))
2523    })?;
2524    let boundaries =
2525        fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
2526    let zone_errors = fallow_config::validate_rule_pack_zone_references(
2527        root,
2528        &config.rule_packs,
2529        &packs,
2530        &boundaries,
2531    );
2532    if !zone_errors.is_empty() {
2533        let joined = zone_errors
2534            .iter()
2535            .map(ToString::to_string)
2536            .collect::<Vec<_>>()
2537            .join("\n  - ");
2538        return Err(FallowError::config(format!(
2539            "invalid rule pack:\n  - {joined}"
2540        )));
2541    }
2542    Ok((
2543        config.resolve(
2544            root.to_path_buf(),
2545            fallow_config::OutputFormat::Human,
2546            num_cpus(),
2547            false,
2548            true, // quiet: LSP/programmatic callers don't need progress bars
2549            None, // LSP/programmatic embedders use the default cache cap
2550        ),
2551        Some(path),
2552    ))
2553}
2554
2555/// Create a default config for a project root.
2556///
2557/// `analyze_project` is the dead-code entry point used by the LSP and other
2558/// programmatic embedders. When the loaded config uses the per-analysis
2559/// production form (`production: { deadCode: true, ... }`), the production
2560/// flag must be flattened to the dead-code analysis here. Otherwise
2561/// `ResolvedConfig::resolve` calls `.global()` which returns false for the
2562/// per-analysis variant and the production-mode rule overrides
2563/// (`unused_dev_dependencies: off`, etc.) plus `resolved.production = true`
2564/// are silently dropped.
2565#[cfg_attr(
2566    not(test),
2567    allow(
2568        dead_code,
2569        reason = "config resolution fallback is exercised by session tests"
2570    )
2571)]
2572pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
2573    config_for_project(root, None).map_or_else(
2574        |_| {
2575            fallow_config::FallowConfig::default().resolve(
2576                root.to_path_buf(),
2577                fallow_config::OutputFormat::Human,
2578                num_cpus(),
2579                false,
2580                true,
2581                None,
2582            )
2583        },
2584        |(config, _)| config,
2585    )
2586}
2587
2588fn num_cpus() -> usize {
2589    std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
2590}
2591
2592#[cfg(test)]
2593mod tests {
2594    use super::{
2595        AnalysisSession, bucket_files_by_workspace, bucket_files_by_workspace_roots,
2596        collect_config_search_roots, credit_workspace_package_usage, default_config,
2597        format_undeclared_workspace_warning, parse_analysis_modules, plugin_config_hash,
2598        resolver_options_hash, warn_undeclared_workspaces,
2599    };
2600    use std::path::{Path, PathBuf};
2601    use std::time::Instant;
2602
2603    use fallow_config::{
2604        AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
2605    };
2606    use fallow_types::discover::{DiscoveredFile, FileId};
2607    use fallow_types::extract::{ImportInfo, ImportedName};
2608
2609    fn plugin_result() -> crate::plugins::AggregatedPluginResult {
2610        let mut result = crate::plugins::AggregatedPluginResult::default();
2611        result.active_plugins.push("nuxt".to_string());
2612        result
2613            .path_aliases
2614            .push(("@/".to_string(), "src/".to_string()));
2615        result
2616    }
2617
2618    #[test]
2619    fn commonjs_internal_import_credits_workspace_package_usage() {
2620        let workspace = fallow_config::WorkspaceInfo {
2621            root: PathBuf::from("/repo/packages/shared"),
2622            name: "@repo/shared".to_string(),
2623            is_internal_dependency: true,
2624        };
2625        let resolved = vec![crate::resolve::ResolvedModule {
2626            file_id: FileId(0),
2627            resolved_imports: vec![crate::resolve::ResolvedImport {
2628                info: ImportInfo {
2629                    source: "@repo/shared".to_string(),
2630                    imported_name: ImportedName::Namespace,
2631                    local_name: "shared".to_string(),
2632                    is_type_only: false,
2633                    is_type_only_star: false,
2634                    from_style: false,
2635                    span: oxc_span::Span::new(0, 20),
2636                    source_span: oxc_span::Span::new(8, 20),
2637                },
2638                target: crate::resolve::ResolveResult::CommonJsInternalModule(FileId(1)),
2639            }],
2640            ..crate::resolve::ResolvedModule::default()
2641        }];
2642        let mut graph = crate::graph::ModuleGraph::build(&[], &[], &[]);
2643
2644        credit_workspace_package_usage(&mut graph, &resolved, &[workspace]);
2645
2646        assert_eq!(
2647            graph.package_usage.get("@repo/shared"),
2648            Some(&vec![FileId(0)])
2649        );
2650    }
2651
2652    #[test]
2653    fn graph_cache_resolver_hash_includes_project_root() {
2654        let dir_a = tempfile::tempdir().expect("create temp dir a");
2655        let dir_b = tempfile::tempdir().expect("create temp dir b");
2656        let config_a = session_config(dir_a.path());
2657        let config_b = session_config(dir_b.path());
2658
2659        assert_ne!(
2660            resolver_options_hash(&config_a),
2661            resolver_options_hash(&config_b),
2662            "shared cache dirs must not reuse graphs across project roots"
2663        );
2664    }
2665
2666    #[test]
2667    fn graph_cache_resolver_hash_includes_resolve_conditions() {
2668        let dir = tempfile::tempdir().expect("create temp dir");
2669        let config_a = session_config(dir.path());
2670        let mut config_b = session_config(dir.path());
2671        config_b.resolve.conditions.push("react-server".to_string());
2672
2673        assert_ne!(
2674            resolver_options_hash(&config_a),
2675            resolver_options_hash(&config_b),
2676            "resolve condition changes must invalidate the graph cache"
2677        );
2678    }
2679
2680    #[test]
2681    fn graph_cache_plugin_hash_includes_auto_imports() {
2682        let mut without_auto_import = plugin_result();
2683        let mut with_auto_import = plugin_result();
2684        with_auto_import.auto_imports.push(AutoImportRule {
2685            name: "useCounter".to_string(),
2686            source: PathBuf::from("/project/composables/useCounter.ts"),
2687            kind: AutoImportKind::Named,
2688        });
2689
2690        assert_ne!(
2691            plugin_config_hash(&without_auto_import),
2692            plugin_config_hash(&with_auto_import),
2693            "auto-import edge changes must invalidate the graph cache"
2694        );
2695
2696        without_auto_import.auto_imports.push(AutoImportRule {
2697            name: "useCounter".to_string(),
2698            source: PathBuf::from("/project/composables/useCounter.ts"),
2699            kind: AutoImportKind::Default,
2700        });
2701        assert_ne!(
2702            plugin_config_hash(&without_auto_import),
2703            plugin_config_hash(&with_auto_import),
2704            "auto-import kind changes must invalidate the graph cache"
2705        );
2706    }
2707
2708    #[test]
2709    fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
2710        let base = plugin_result();
2711        let mut with_scss = base.clone();
2712        with_scss
2713            .scss_include_paths
2714            .push(PathBuf::from("/project/styles"));
2715        assert_ne!(
2716            plugin_config_hash(&base),
2717            plugin_config_hash(&with_scss),
2718            "SCSS include path changes must invalidate the graph cache"
2719        );
2720
2721        let mut with_static_dir = base.clone();
2722        with_static_dir
2723            .static_dir_mappings
2724            .push((PathBuf::from("/project/public"), "/".to_string()));
2725        assert_ne!(
2726            plugin_config_hash(&base),
2727            plugin_config_hash(&with_static_dir),
2728            "static directory mapping changes must invalidate the graph cache"
2729        );
2730    }
2731
2732    fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
2733        WorkspaceDiagnostic::new(
2734            root,
2735            root.join(relative),
2736            WorkspaceDiagnosticKind::UndeclaredWorkspace,
2737        )
2738    }
2739
2740    fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
2741        let mut config = default_config(root);
2742        config.no_cache = true;
2743        config.quiet = true;
2744        config
2745    }
2746
2747    fn write_session_fixture(root: &Path) {
2748        let src = root.join("src");
2749        std::fs::create_dir_all(&src).expect("create src");
2750        std::fs::write(
2751            root.join("package.json"),
2752            r#"{"name":"session-fixture","type":"module"}"#,
2753        )
2754        .expect("write package json");
2755        std::fs::write(
2756            src.join("index.ts"),
2757            "import { used } from './used';\nconsole.log(used);\n",
2758        )
2759        .expect("write index");
2760        std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
2761    }
2762
2763    #[test]
2764    fn analysis_session_discovers_project_files() {
2765        let dir = tempfile::tempdir().expect("create temp dir");
2766        write_session_fixture(dir.path());
2767        let config = session_config(dir.path());
2768
2769        let session = AnalysisSession::new(&config).expect("session setup should succeed");
2770
2771        assert!(
2772            session
2773                .files()
2774                .iter()
2775                .any(|file| file.path.ends_with("src/index.ts")),
2776            "session should own discovered project files"
2777        );
2778        assert_eq!(session.workspaces().len(), 0);
2779    }
2780
2781    #[test]
2782    fn direct_core_parse_surfaces_source_read_failure_diagnostic() {
2783        let project = tempfile::tempdir().expect("create project");
2784        let root = project.path();
2785        let paths = ["a.ts", "b.ts", "c.ts"].map(|name| root.join(name));
2786        for (index, path) in paths.iter().enumerate() {
2787            std::fs::write(path, format!("export const value{index} = {index};\n"))
2788                .expect("write source");
2789        }
2790        let files: Vec<DiscoveredFile> = paths
2791            .iter()
2792            .enumerate()
2793            .map(|(index, path)| DiscoveredFile {
2794                id: FileId(u32::try_from(index).expect("test index fits u32")),
2795                path: path.clone(),
2796                size_bytes: std::fs::metadata(path).expect("source metadata").len(),
2797            })
2798            .collect();
2799        std::fs::remove_file(&paths[1]).expect("remove source after discovery");
2800        let config = session_config(root);
2801
2802        let parsed = parse_analysis_modules(&config, &files, false, Instant::now());
2803
2804        assert_eq!(
2805            parsed
2806                .modules
2807                .iter()
2808                .map(|module| module.file_id)
2809                .collect::<Vec<_>>(),
2810            vec![FileId(0), FileId(2)]
2811        );
2812        let diagnostics = fallow_config::workspace_diagnostics_for(root);
2813        let diagnostic = diagnostics
2814            .iter()
2815            .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2816            .expect("source read failure diagnostic");
2817        assert_eq!(diagnostic.path, paths[1]);
2818        assert!(matches!(
2819            diagnostic.kind,
2820            WorkspaceDiagnosticKind::SourceReadFailure { .. }
2821        ));
2822    }
2823
2824    #[test]
2825    fn analysis_session_parses_owned_modules() {
2826        let dir = tempfile::tempdir().expect("create temp dir");
2827        write_session_fixture(dir.path());
2828        let config = session_config(dir.path());
2829
2830        let session = AnalysisSession::new(&config).expect("session setup should succeed");
2831        let parsed = session.parse_modules(false);
2832
2833        assert!(
2834            parsed
2835                .modules
2836                .iter()
2837                .any(|module| session.files()[module.file_id.0 as usize]
2838                    .path
2839                    .ends_with("src/index.ts")),
2840            "session parsing should return modules keyed to session files"
2841        );
2842    }
2843
2844    #[test]
2845    fn undeclared_workspace_warning_is_singular_for_one_path() {
2846        let root = Path::new("/repo");
2847        let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
2848            .expect("warning should be rendered");
2849
2850        assert_eq!(
2851            warning,
2852            "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."
2853        );
2854    }
2855
2856    #[test]
2857    fn undeclared_workspace_warning_summarizes_many_paths() {
2858        let root = PathBuf::from("/repo");
2859        let diagnostics = [
2860            "examples/a",
2861            "examples/b",
2862            "examples/c",
2863            "examples/d",
2864            "examples/e",
2865            "examples/f",
2866        ]
2867        .into_iter()
2868        .map(|path| diag(&root, path))
2869        .collect::<Vec<_>>();
2870
2871        let warning = format_undeclared_workspace_warning(&root, &diagnostics)
2872            .expect("warning should be rendered");
2873
2874        assert_eq!(
2875            warning,
2876            "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."
2877        );
2878    }
2879
2880    #[test]
2881    fn collect_config_search_roots_includes_file_ancestors_once() {
2882        let root = PathBuf::from("/repo");
2883        let search_roots = collect_config_search_roots(
2884            &root,
2885            &[
2886                root.join("apps/query/src/main.ts"),
2887                root.join("packages/shared/lib/index.ts"),
2888            ],
2889        );
2890
2891        assert_eq!(
2892            search_roots,
2893            vec![
2894                root.clone(),
2895                root.join("apps"),
2896                root.join("apps/query"),
2897                root.join("apps/query/src"),
2898                root.join("packages"),
2899                root.join("packages/shared"),
2900                root.join("packages/shared/lib"),
2901            ]
2902        );
2903    }
2904
2905    #[test]
2906    fn bucket_files_by_workspace_uses_workspace_relative_paths() {
2907        let root = PathBuf::from("/repo");
2908        let ui = fallow_config::WorkspaceInfo {
2909            root: root.join("apps/ui"),
2910            name: "ui".to_string(),
2911            is_internal_dependency: false,
2912        };
2913        let api = fallow_config::WorkspaceInfo {
2914            root: root.join("apps/api"),
2915            name: "api".to_string(),
2916            is_internal_dependency: false,
2917        };
2918        let workspace_pkgs = vec![
2919            (
2920                ui,
2921                fallow_config::PackageJson {
2922                    name: Some("ui".to_string()),
2923                    ..Default::default()
2924                },
2925            ),
2926            (
2927                api,
2928                fallow_config::PackageJson {
2929                    name: Some("api".to_string()),
2930                    ..Default::default()
2931                },
2932            ),
2933        ];
2934        let files = vec![
2935            root.join("apps/ui/vite.config.ts"),
2936            root.join("apps/ui/src/main.ts"),
2937            root.join("apps/api/src/server.ts"),
2938            root.join("tools/build.ts"),
2939        ];
2940
2941        let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
2942
2943        assert_eq!(
2944            buckets[0],
2945            vec![
2946                (
2947                    root.join("apps/ui/vite.config.ts"),
2948                    "vite.config.ts".to_string()
2949                ),
2950                (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
2951            ]
2952        );
2953        assert_eq!(
2954            buckets[1],
2955            vec![(
2956                root.join("apps/api/src/server.ts"),
2957                "src/server.ts".to_string()
2958            )]
2959        );
2960    }
2961
2962    #[test]
2963    fn workspace_bucketing_preserves_first_declared_match_and_file_order() {
2964        let root = PathBuf::from("/repo");
2965        let parent = root.join("apps");
2966        let child = parent.join("web");
2967        let nested_first = child.join("src/first.ts");
2968        let nested_second = child.join("src/second.ts");
2969        let unmatched = root.join("tools/build.ts");
2970        let files = vec![nested_first.clone(), unmatched, nested_second.clone()];
2971
2972        // The relative path preserves the input path's original separators, which
2973        // are mixed on Windows when the fixture is built via multiple `join` calls
2974        // (`web\src/first.ts`). Normalize separators before comparing so the
2975        // assertion checks bucketing + ordering, not host path formatting.
2976        let normalize = |bucket: &[(PathBuf, String)]| -> Vec<(PathBuf, String)> {
2977            bucket
2978                .iter()
2979                .map(|(path, rel)| (path.clone(), rel.replace('\\', "/")))
2980                .collect()
2981        };
2982
2983        let parent_first = bucket_files_by_workspace_roots(&[&parent, &child, &child], &files);
2984        assert_eq!(
2985            normalize(&parent_first[0]),
2986            vec![
2987                (nested_first.clone(), "web/src/first.ts".to_string()),
2988                (nested_second.clone(), "web/src/second.ts".to_string()),
2989            ]
2990        );
2991        assert!(parent_first[1].is_empty());
2992        assert!(parent_first[2].is_empty());
2993
2994        let child_first = bucket_files_by_workspace_roots(&[&child, &parent], &files);
2995        assert_eq!(
2996            normalize(&child_first[0]),
2997            vec![
2998                (nested_first, "src/first.ts".to_string()),
2999                (nested_second, "src/second.ts".to_string()),
3000            ]
3001        );
3002        assert!(child_first[1].is_empty());
3003    }
3004
3005    #[test]
3006    fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
3007        let dir = tempfile::tempdir().expect("create temp dir");
3008        let pkg_good = dir.path().join("packages").join("good");
3009        let pkg_bad = dir.path().join("packages").join("bad");
3010        std::fs::create_dir_all(&pkg_good).unwrap();
3011        std::fs::create_dir_all(&pkg_bad).unwrap();
3012        std::fs::write(
3013            dir.path().join("package.json"),
3014            r#"{"workspaces": ["packages/*"]}"#,
3015        )
3016        .unwrap();
3017        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
3018        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
3019
3020        let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
3021            dir.path(),
3022            &globset::GlobSet::empty(),
3023        )
3024        .expect("root package.json is valid");
3025        assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
3026        fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
3027
3028        warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
3029
3030        let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3031        let mut malformed = 0;
3032        let mut undeclared_for_bad = 0;
3033        for diag in &diagnostics {
3034            if matches!(
3035                diag.kind,
3036                WorkspaceDiagnosticKind::MalformedPackageJson { .. }
3037            ) && diag.path.ends_with("bad")
3038            {
3039                malformed += 1;
3040            }
3041            if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
3042                && diag.path.ends_with("bad")
3043            {
3044                undeclared_for_bad += 1;
3045            }
3046        }
3047        assert_eq!(
3048            malformed, 1,
3049            "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
3050        );
3051        assert_eq!(
3052            undeclared_for_bad, 0,
3053            "warn_undeclared_workspaces must NOT re-flag a path that already \
3054             carries MalformedPackageJson; got duplicates: {diagnostics:?}"
3055        );
3056    }
3057}