Skip to main content

fallow_core/
lib.rs

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