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