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
1056struct TimedAnalysis {
1057    result: AnalysisResults,
1058    elapsed_ms: f64,
1059}
1060
1061fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
1062    let t = Instant::now();
1063    let entry_points = discover_all_entry_points(
1064        input.config,
1065        input.files,
1066        input.workspaces,
1067        input.root_pkg,
1068        input.workspace_pkgs,
1069        input.plugin_result,
1070    );
1071    let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
1072    let summary = summarize_entry_points(&entry_points.all);
1073    let count = entry_points.all.len();
1074
1075    TimedEntryPoints {
1076        entry_points,
1077        summary,
1078        count,
1079        elapsed_ms,
1080    }
1081}
1082
1083fn try_load_analysis_graph_cache(
1084    input: &AnalysisCoreSharedInput<'_>,
1085    entry_points: &TimedEntryPoints,
1086    modules: &[extract::ModuleInfo],
1087) -> Option<GraphCacheHit> {
1088    if input.config.no_cache {
1089        return None;
1090    }
1091
1092    let t = Instant::now();
1093    input.progress.set_stage("loading module graph cache...");
1094    let current = build_graph_cache_manifest(
1095        input.config,
1096        input.plugin_result,
1097        &entry_points.entry_points,
1098        input.files,
1099    );
1100    let store = graph_cache::GraphCacheStore::load(&input.config.cache_dir)?;
1101    if store.manifest.matches_inputs(&current) {
1102        let resolved = graph_cache::restore_resolved_modules(
1103            &input.config.root,
1104            modules,
1105            input.files,
1106            &store.resolved_modules,
1107        )?;
1108        tracing::debug!("Graph cache hit: skipping import resolution and graph build");
1109
1110        return Some(GraphCacheHit {
1111            graph: store.graph,
1112            resolved,
1113            elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1114        });
1115    }
1116
1117    if !store.manifest.matches_resolution_inputs(&current) {
1118        return None;
1119    }
1120
1121    let resolved = graph_cache::restore_resolved_modules(
1122        &input.config.root,
1123        modules,
1124        input.files,
1125        &store.resolved_modules,
1126    )?;
1127    tracing::debug!("Graph resolver cache hit: skipping import resolution and rebuilding graph");
1128    let graph = build_analysis_graph_timed(input, &resolved, entry_points, modules);
1129
1130    Some(GraphCacheHit {
1131        graph: graph.graph,
1132        resolved,
1133        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1134    })
1135}
1136
1137fn resolve_analysis_imports_timed(
1138    input: &AnalysisCoreSharedInput<'_>,
1139    modules: &[extract::ModuleInfo],
1140) -> TimedResolvedModules {
1141    let t = Instant::now();
1142    input.progress.set_stage("resolving imports...");
1143    let resolved = resolve_analysis_imports(
1144        modules,
1145        input.files,
1146        input.workspaces,
1147        input.plugin_result,
1148        input.config,
1149    );
1150    TimedResolvedModules {
1151        resolved,
1152        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1153    }
1154}
1155
1156fn build_analysis_graph_timed(
1157    input: &AnalysisCoreSharedInput<'_>,
1158    resolved: &[resolve::ResolvedModule],
1159    entry_points: &TimedEntryPoints,
1160    modules: &[extract::ModuleInfo],
1161) -> TimedGraph {
1162    let t = Instant::now();
1163    input.progress.set_stage("building module graph...");
1164    let graph = build_analysis_graph(&BuildAnalysisGraphInput {
1165        config: input.config,
1166        plugin_result: input.plugin_result,
1167        resolved,
1168        entry_points: &entry_points.entry_points,
1169        files: input.files,
1170        modules,
1171        workspaces: input.workspaces,
1172    });
1173    TimedGraph {
1174        graph,
1175        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1176    }
1177}
1178
1179fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
1180    for module in modules {
1181        module.release_resolution_payload();
1182    }
1183}
1184
1185fn analyze_dead_code_timed(
1186    input: &AnalysisCoreSharedInput<'_>,
1187    graph: &graph::ModuleGraph,
1188    resolved: &[resolve::ResolvedModule],
1189    modules: &[extract::ModuleInfo],
1190    collect_usages: bool,
1191    entry_point_summary: results::EntryPointSummary,
1192) -> TimedAnalysis {
1193    let t = Instant::now();
1194    input.progress.set_stage("analyzing...");
1195    #[expect(
1196        deprecated,
1197        reason = "Core-internal policy keeps workspace path-dependency calls while warning external fallow-core consumers"
1198    )]
1199    let mut result = analyze::find_dead_code_full(
1200        graph,
1201        input.config,
1202        resolved,
1203        Some(input.plugin_result),
1204        input.workspaces,
1205        modules,
1206        collect_usages,
1207    );
1208    result.entry_point_summary = Some(entry_point_summary);
1209    TimedAnalysis {
1210        result,
1211        elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1212    }
1213}
1214
1215fn analyze_full(
1216    config: &ResolvedConfig,
1217    retain: bool,
1218    collect_usages: bool,
1219    need_complexity: bool,
1220    retain_modules: bool,
1221) -> Result<AnalysisOutput, FallowError> {
1222    let _span = tracing::info_span!("fallow_analyze").entered();
1223    AnalysisSession::new(config).run_full(retain, collect_usages, need_complexity, retain_modules)
1224}
1225
1226fn full_analysis_pipeline_profile(
1227    timings: &PreludeTimings,
1228    pipeline_start: Instant,
1229    files: &[discover::DiscoveredFile],
1230    workspaces: &[fallow_config::WorkspaceInfo],
1231    core: &OwnedAnalysisCore,
1232    metrics: &ParseMetrics,
1233) -> PipelineProfile {
1234    let prelude = prelude_metrics(
1235        timings,
1236        pipeline_start,
1237        files,
1238        workspaces,
1239        core.modules.len(),
1240    );
1241    full_pipeline_profile(&prelude, core, metrics)
1242}
1243
1244/// Assemble the `AnalysisOutput` for the full pipeline, honoring the graph/module
1245/// retention flags and computing per-file content hashes.
1246fn assemble_full_output(
1247    core: OwnedAnalysisCore,
1248    plugin_result: plugins::AggregatedPluginResult,
1249    profile: &PipelineProfile,
1250    files: &[discover::DiscoveredFile],
1251    retain: bool,
1252    retain_modules: bool,
1253) -> AnalysisOutput {
1254    let file_hashes = collect_file_hashes(&core.modules, files);
1255    AnalysisOutput {
1256        results: core.result,
1257        timings: retained_pipeline_timings(retain, profile),
1258        graph: if retain { Some(core.graph) } else { None },
1259        modules: if retain_modules {
1260            Some(core.modules)
1261        } else {
1262            None
1263        },
1264        files: if retain_modules {
1265            Some(files.to_vec())
1266        } else {
1267            None
1268        },
1269        script_used_packages: plugin_result.script_used_packages,
1270        file_hashes,
1271    }
1272}
1273
1274/// Result of the freshly-parsed analysis core; returns the owned `modules` (so the
1275/// caller can retain them) plus the per-phase timings.
1276struct OwnedAnalysisCore {
1277    result: AnalysisResults,
1278    graph: graph::ModuleGraph,
1279    modules: Vec<extract::ModuleInfo>,
1280    entry_point_count: usize,
1281    entry_points_ms: f64,
1282    resolve_ms: f64,
1283    graph_ms: f64,
1284    analyze_ms: f64,
1285}
1286
1287/// Assemble the `PipelineProfile` for the full (freshly parsed) pipeline path.
1288fn full_pipeline_profile(
1289    prelude: &PreludeMetrics,
1290    core: &OwnedAnalysisCore,
1291    parse: &ParseMetrics,
1292) -> PipelineProfile {
1293    PipelineProfile {
1294        discover_ms: prelude.discover_ms,
1295        workspaces_ms: prelude.workspaces_ms,
1296        plugins_ms: prelude.plugins_ms,
1297        scripts_ms: prelude.scripts_ms,
1298        parse_ms: parse.parse_ms,
1299        cache_ms: parse.cache_ms,
1300        entry_points_ms: core.entry_points_ms,
1301        resolve_ms: core.resolve_ms,
1302        graph_ms: core.graph_ms,
1303        analyze_ms: core.analyze_ms,
1304        total_ms: prelude.total_ms,
1305        file_count: prelude.file_count,
1306        workspace_count: prelude.workspace_count,
1307        module_count: prelude.module_count,
1308        entry_point_count: core.entry_point_count,
1309        cache_hits: parse.cache_hits,
1310        cache_misses: parse.cache_misses,
1311        parse_cpu_ms: parse.parse_cpu_ms,
1312    }
1313}
1314
1315#[derive(Clone, Copy)]
1316struct PipelineProfile {
1317    discover_ms: f64,
1318    workspaces_ms: f64,
1319    plugins_ms: f64,
1320    scripts_ms: f64,
1321    parse_ms: f64,
1322    cache_ms: f64,
1323    entry_points_ms: f64,
1324    resolve_ms: f64,
1325    graph_ms: f64,
1326    analyze_ms: f64,
1327    total_ms: f64,
1328    file_count: usize,
1329    workspace_count: usize,
1330    module_count: usize,
1331    entry_point_count: usize,
1332    cache_hits: usize,
1333    cache_misses: usize,
1334    parse_cpu_ms: f64,
1335}
1336
1337struct AnalysisParseOutput {
1338    modules: Vec<extract::ModuleInfo>,
1339    metrics: ParseMetrics,
1340}
1341
1342/// Parse/cache phase metrics carried into the full-pipeline `PipelineProfile`.
1343struct ParseMetrics {
1344    parse_ms: f64,
1345    cache_ms: f64,
1346    cache_hits: usize,
1347    cache_misses: usize,
1348    parse_cpu_ms: f64,
1349}
1350
1351impl From<AnalysisParseMetrics> for ParseMetrics {
1352    fn from(metrics: AnalysisParseMetrics) -> Self {
1353        Self {
1354            parse_ms: metrics.parse_ms,
1355            cache_ms: metrics.cache_ms,
1356            cache_hits: metrics.cache_hits,
1357            cache_misses: metrics.cache_misses,
1358            parse_cpu_ms: metrics.parse_cpu_ms,
1359        }
1360    }
1361}
1362
1363fn parse_analysis_modules(
1364    config: &ResolvedConfig,
1365    files: &[discover::DiscoveredFile],
1366    need_complexity: bool,
1367    start: Instant,
1368) -> AnalysisParseOutput {
1369    let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1370    let mut cache_store = if config.no_cache {
1371        None
1372    } else {
1373        cache::CacheStore::load(
1374            &config.cache_dir,
1375            config.cache_config_hash,
1376            cache_max_size_bytes,
1377        )
1378    };
1379
1380    let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
1381    let _ = fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
1382    let modules = parse_result.modules;
1383    let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1384    let cache_ms = update_parse_cache_if_enabled(
1385        config,
1386        &mut cache_store,
1387        &modules,
1388        files,
1389        cache_max_size_bytes,
1390    );
1391
1392    AnalysisParseOutput {
1393        modules,
1394        metrics: ParseMetrics {
1395            parse_ms,
1396            cache_ms,
1397            cache_hits: parse_result.cache_hits,
1398            cache_misses: parse_result.cache_misses,
1399            parse_cpu_ms: parse_result.parse_cpu_ms,
1400        },
1401    }
1402}
1403
1404fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1405    retain.then_some(PipelineTimings {
1406        discover_files_ms: profile.discover_ms,
1407        file_count: profile.file_count,
1408        workspaces_ms: profile.workspaces_ms,
1409        workspace_count: profile.workspace_count,
1410        plugins_ms: profile.plugins_ms,
1411        script_analysis_ms: profile.scripts_ms,
1412        parse_extract_ms: profile.parse_ms,
1413        parse_cpu_ms: profile.parse_cpu_ms,
1414        module_count: profile.module_count,
1415        cache_hits: profile.cache_hits,
1416        cache_misses: profile.cache_misses,
1417        cache_update_ms: profile.cache_ms,
1418        entry_points_ms: profile.entry_points_ms,
1419        entry_point_count: profile.entry_point_count,
1420        resolve_imports_ms: profile.resolve_ms,
1421        build_graph_ms: profile.graph_ms,
1422        analyze_ms: profile.analyze_ms,
1423        duplication_ms: None,
1424        total_ms: profile.total_ms,
1425    })
1426}
1427
1428fn update_parse_cache_if_enabled(
1429    config: &ResolvedConfig,
1430    cache_store: &mut Option<cache::CacheStore>,
1431    modules: &[extract::ModuleInfo],
1432    files: &[discover::DiscoveredFile],
1433    cache_max_size_bytes: usize,
1434) -> f64 {
1435    let t = Instant::now();
1436    if !config.no_cache {
1437        let store = cache_store.get_or_insert_with(cache::CacheStore::new);
1438        if update_cache(store, modules, files)
1439            && let Err(error) = store.save(
1440                &config.cache_dir,
1441                config.cache_config_hash,
1442                cache_max_size_bytes,
1443            )
1444        {
1445            tracing::warn!("Failed to save cache: {error}");
1446        }
1447    }
1448    t.elapsed().as_secs_f64() * 1000.0
1449}
1450
1451fn resolve_analysis_imports(
1452    modules: &[extract::ModuleInfo],
1453    files: &[discover::DiscoveredFile],
1454    workspaces: &[fallow_config::WorkspaceInfo],
1455    plugin_result: &plugins::AggregatedPluginResult,
1456    config: &ResolvedConfig,
1457) -> Vec<resolve::ResolvedModule> {
1458    let mut resolved = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1459        modules,
1460        files,
1461        workspaces,
1462        active_plugins: &plugin_result.active_plugins,
1463        path_aliases: &plugin_result.path_aliases,
1464        auto_imports: &plugin_result.auto_imports,
1465        scss_include_paths: &plugin_result.scss_include_paths,
1466        static_dir_mappings: &plugin_result.static_dir_mappings,
1467        root: &config.root,
1468        extra_conditions: &config.resolve.conditions,
1469    });
1470    external_style_usage::augment_external_style_package_usage(
1471        &mut resolved,
1472        config,
1473        workspaces,
1474        plugin_result,
1475    );
1476    resolved
1477}
1478
1479struct BuildAnalysisGraphInput<'a> {
1480    config: &'a ResolvedConfig,
1481    plugin_result: &'a plugins::AggregatedPluginResult,
1482    resolved: &'a [resolve::ResolvedModule],
1483    entry_points: &'a discover::CategorizedEntryPoints,
1484    files: &'a [discover::DiscoveredFile],
1485    modules: &'a [extract::ModuleInfo],
1486    workspaces: &'a [fallow_config::WorkspaceInfo],
1487}
1488
1489/// Build the analysis graph and persist it for the next identical run.
1490///
1491/// The warm hit path happens before import resolution in
1492/// `try_load_analysis_graph_cache`. This miss path always builds fresh, runs
1493/// both credit steps, and persists the graph plus resolver outputs for next
1494/// time. The cache is gated on `config.no_cache` and is a strict performance
1495/// optimization: a cache hit produces identical analysis results.
1496fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
1497    let caching_enabled = !input.config.no_cache;
1498    let current_manifest = caching_enabled.then(|| {
1499        build_graph_cache_manifest(
1500            input.config,
1501            input.plugin_result,
1502            input.entry_points,
1503            input.files,
1504        )
1505    });
1506
1507    let mut graph = graph::ModuleGraph::build_with_reachability_roots(
1508        input.resolved,
1509        &input.entry_points.all,
1510        &input.entry_points.runtime,
1511        &input.entry_points.test,
1512        input.files,
1513    );
1514    credit_package_path_references(&mut graph, input.modules);
1515    credit_workspace_package_usage(&mut graph, input.resolved, input.workspaces);
1516
1517    if let Some(manifest) = current_manifest {
1518        let Some(resolved_modules) =
1519            graph_cache::cache_resolved_modules(&input.config.root, input.files, input.resolved)
1520        else {
1521            return graph;
1522        };
1523        let store = graph_cache::GraphCacheStore {
1524            version: graph_cache::GRAPH_CACHE_VERSION,
1525            manifest,
1526            graph,
1527            resolved_modules,
1528        };
1529        store.save(&input.config.cache_dir);
1530        // `save` borrows the store, so the freshly built graph is moved back out
1531        // and returned in-memory. The warm path loads-and-reconstructs an
1532        // identical graph from this same persisted blob (proven by the
1533        // cold-vs-warm correctness gate).
1534        return store.graph;
1535    }
1536
1537    graph
1538}
1539
1540/// Build the current `GraphCacheManifest` from the run's discovered files and
1541/// graph-affecting option hashes.
1542fn build_graph_cache_manifest(
1543    config: &ResolvedConfig,
1544    plugin_result: &plugins::AggregatedPluginResult,
1545    entry_points: &discover::CategorizedEntryPoints,
1546    files: &[discover::DiscoveredFile],
1547) -> graph_cache::GraphCacheManifest {
1548    let mode = graph_cache::GraphCacheMode::new(
1549        resolver_options_hash(config),
1550        entry_points_hash(entry_points),
1551        plugin_config_hash(plugin_result),
1552    );
1553    graph_cache::GraphCacheManifest::from_discovered_files(&config.root, files, mode, |path| {
1554        std::fs::metadata(path).map_or(
1555            fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
1556            |metadata| {
1557                fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata)
1558            },
1559        )
1560    })
1561}
1562
1563/// Hash the resolver-affecting options: the project root, extraction config
1564/// hash (which already folds tsconfig / resolver-relevant config), and the
1565/// user-supplied resolve `conditions`.
1566///
1567/// `production` and `ignore_patterns` intentionally stay out of this hash:
1568/// they shape discovery, so changed file sets already miss through stable file
1569/// keys and source fingerprints in the manifest.
1570fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
1571    use std::hash::{Hash, Hasher};
1572    let mut hasher = rustc_hash::FxHasher::default();
1573    config.root.hash(&mut hasher);
1574    config.cache_config_hash.hash(&mut hasher);
1575    config.resolve.conditions.hash(&mut hasher);
1576    hasher.finish()
1577}
1578
1579/// Hash the entry-point set (sorted paths per role) so any change in reachability
1580/// roots misses the cache.
1581fn entry_points_hash(entry_points: &discover::CategorizedEntryPoints) -> u64 {
1582    use std::hash::{Hash, Hasher};
1583    let mut hasher = rustc_hash::FxHasher::default();
1584    for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
1585        let mut paths: Vec<&std::path::Path> = role.iter().map(|ep| ep.path.as_path()).collect();
1586        paths.sort_unstable();
1587        paths.len().hash(&mut hasher);
1588        for path in paths {
1589            path.hash(&mut hasher);
1590        }
1591    }
1592    hasher.finish()
1593}
1594
1595/// Hash the plugin-derived graph-affecting configuration.
1596fn plugin_config_hash(plugin_result: &plugins::AggregatedPluginResult) -> u64 {
1597    use std::hash::{Hash, Hasher};
1598    let mut hasher = rustc_hash::FxHasher::default();
1599
1600    hash_active_plugins(plugin_result, &mut hasher);
1601    hash_path_aliases(plugin_result, &mut hasher);
1602
1603    let mut auto_imports: Vec<(&str, &std::path::Path, fallow_config::AutoImportKind)> =
1604        plugin_result
1605            .auto_imports
1606            .iter()
1607            .map(|rule| (rule.name.as_str(), rule.source.as_path(), rule.kind))
1608            .collect();
1609    auto_imports.sort_unstable_by(|a, b| {
1610        a.0.cmp(b.0)
1611            .then_with(|| a.1.cmp(b.1))
1612            .then_with(|| auto_import_kind_rank(a.2).cmp(&auto_import_kind_rank(b.2)))
1613    });
1614    auto_imports.len().hash(&mut hasher);
1615    for (name, source, kind) in auto_imports {
1616        name.hash(&mut hasher);
1617        source.hash(&mut hasher);
1618        auto_import_kind_rank(kind).hash(&mut hasher);
1619    }
1620
1621    let mut scss_include_paths: Vec<&std::path::Path> = plugin_result
1622        .scss_include_paths
1623        .iter()
1624        .map(std::path::PathBuf::as_path)
1625        .collect();
1626    scss_include_paths.sort_unstable();
1627    scss_include_paths.len().hash(&mut hasher);
1628    for path in scss_include_paths {
1629        path.hash(&mut hasher);
1630    }
1631
1632    let mut static_dir_mappings: Vec<(&std::path::Path, &str)> = plugin_result
1633        .static_dir_mappings
1634        .iter()
1635        .map(|(from_dir, mount)| (from_dir.as_path(), mount.as_str()))
1636        .collect();
1637    static_dir_mappings.sort_unstable();
1638    static_dir_mappings.len().hash(&mut hasher);
1639    for (from_dir, mount) in static_dir_mappings {
1640        from_dir.hash(&mut hasher);
1641        mount.hash(&mut hasher);
1642    }
1643
1644    hasher.finish()
1645}
1646
1647fn hash_active_plugins(
1648    plugin_result: &plugins::AggregatedPluginResult,
1649    hasher: &mut rustc_hash::FxHasher,
1650) {
1651    use std::hash::Hash;
1652    let mut active: Vec<&str> = plugin_result
1653        .active_plugins
1654        .iter()
1655        .map(String::as_str)
1656        .collect();
1657    active.sort_unstable();
1658    active.len().hash(hasher);
1659    for name in active {
1660        name.hash(hasher);
1661    }
1662}
1663
1664fn hash_path_aliases(
1665    plugin_result: &plugins::AggregatedPluginResult,
1666    hasher: &mut rustc_hash::FxHasher,
1667) {
1668    use std::hash::Hash;
1669    let mut aliases: Vec<(&str, &str)> = plugin_result
1670        .path_aliases
1671        .iter()
1672        .map(|(prefix, replacement)| (prefix.as_str(), replacement.as_str()))
1673        .collect();
1674    aliases.sort_unstable();
1675    aliases.len().hash(hasher);
1676    for (prefix, replacement) in aliases {
1677        prefix.hash(hasher);
1678        replacement.hash(hasher);
1679    }
1680}
1681
1682fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
1683    match kind {
1684        fallow_config::AutoImportKind::Named => 0,
1685        fallow_config::AutoImportKind::Default => 1,
1686        fallow_config::AutoImportKind::DefaultComponent => 2,
1687    }
1688}
1689
1690fn collect_file_hashes(
1691    modules: &[extract::ModuleInfo],
1692    files: &[discover::DiscoveredFile],
1693) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1694    modules
1695        .iter()
1696        .filter_map(|module| {
1697            files
1698                .get(module.file_id.0 as usize)
1699                .map(|file| (file.path.clone(), module.content_hash))
1700        })
1701        .collect()
1702}
1703
1704fn trace_pipeline_profile(profile: &PipelineProfile) {
1705    let PipelineProfile {
1706        discover_ms,
1707        workspaces_ms,
1708        plugins_ms,
1709        scripts_ms,
1710        parse_ms,
1711        cache_ms,
1712        entry_points_ms,
1713        resolve_ms,
1714        graph_ms,
1715        analyze_ms,
1716        total_ms,
1717        file_count,
1718        module_count,
1719        entry_point_count,
1720        cache_hits,
1721        cache_misses,
1722        ..
1723    } = *profile;
1724    let cache_summary = if cache_hits > 0 {
1725        format!(" ({cache_hits} cached, {cache_misses} parsed)")
1726    } else {
1727        String::new()
1728    };
1729
1730    tracing::debug!(
1731        "\n┌─ Pipeline Profile ─────────────────────────────\n\
1732         │  discover files:   {:>8.1}ms  ({} files)\n\
1733         │  workspaces:       {:>8.1}ms\n\
1734         │  plugins:          {:>8.1}ms\n\
1735         │  script analysis:  {:>8.1}ms\n\
1736         │  parse/extract:    {:>8.1}ms  ({} modules{})\n\
1737         │  cache update:     {:>8.1}ms\n\
1738         │  entry points:     {:>8.1}ms  ({} entries)\n\
1739         │  resolve imports:  {:>8.1}ms\n\
1740         │  build graph:      {:>8.1}ms\n\
1741         │  analyze:          {:>8.1}ms\n\
1742         │  ────────────────────────────────────────────\n\
1743         │  TOTAL:            {:>8.1}ms\n\
1744         └─────────────────────────────────────────────────",
1745        discover_ms,
1746        file_count,
1747        workspaces_ms,
1748        plugins_ms,
1749        scripts_ms,
1750        parse_ms,
1751        module_count,
1752        cache_summary,
1753        cache_ms,
1754        entry_points_ms,
1755        entry_point_count,
1756        resolve_ms,
1757        graph_ms,
1758        analyze_ms,
1759        total_ms,
1760    );
1761}
1762
1763/// Analyze package.json scripts from root and all workspace packages.
1764///
1765/// Populates the plugin result with script-used packages and config file
1766/// entry patterns. Also scans CI config files for binary invocations.
1767fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
1768    PackageJson::load(&config.root.join("package.json")).ok()
1769}
1770
1771fn load_workspace_packages(
1772    workspaces: &[fallow_config::WorkspaceInfo],
1773) -> Vec<LoadedWorkspacePackage> {
1774    workspaces
1775        .iter()
1776        .filter_map(|ws| {
1777            PackageJson::load(&ws.root.join("package.json"))
1778                .ok()
1779                .map(|pkg| (ws.clone(), pkg))
1780        })
1781        .collect()
1782}
1783
1784fn analyze_all_scripts(
1785    config: &ResolvedConfig,
1786    workspaces: &[fallow_config::WorkspaceInfo],
1787    root_pkg: Option<&PackageJson>,
1788    workspace_pkgs: &[LoadedWorkspacePackage],
1789    plugin_result: &mut plugins::AggregatedPluginResult,
1790) {
1791    let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
1792    let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
1793    let all_script_names = collect_all_script_names(root_pkg, workspace_pkgs);
1794
1795    let nm_roots = collect_node_modules_roots(config, workspaces);
1796    let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
1797
1798    analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
1799    analyze_workspace_scripts(
1800        config,
1801        workspace_pkgs,
1802        &bin_map,
1803        &all_dep_set,
1804        plugin_result,
1805    );
1806    analyze_ci_scripts(
1807        config,
1808        &bin_map,
1809        &all_dep_set,
1810        &all_script_names,
1811        plugin_result,
1812    );
1813
1814    plugin_result
1815        .entry_point_roles
1816        .entry("scripts".to_string())
1817        .or_insert(EntryPointRole::Support);
1818}
1819
1820/// Gather sorted, deduped dependency names across the root and workspace packages.
1821fn collect_all_dependency_names(
1822    root_pkg: Option<&PackageJson>,
1823    workspace_pkgs: &[LoadedWorkspacePackage],
1824) -> Vec<String> {
1825    let mut all_dep_names: Vec<String> = Vec::new();
1826    if let Some(pkg) = root_pkg {
1827        all_dep_names.extend(pkg.all_dependency_names());
1828    }
1829    for (_, ws_pkg) in workspace_pkgs {
1830        all_dep_names.extend(ws_pkg.all_dependency_names());
1831    }
1832    all_dep_names.sort_unstable();
1833    all_dep_names.dedup();
1834    all_dep_names
1835}
1836
1837/// Gather the union of script names declared in the root and workspace packages.
1838fn collect_all_script_names(
1839    root_pkg: Option<&PackageJson>,
1840    workspace_pkgs: &[LoadedWorkspacePackage],
1841) -> FxHashSet<String> {
1842    let mut all_script_names: FxHashSet<String> = FxHashSet::default();
1843    if let Some(pkg) = root_pkg
1844        && let Some(ref pkg_scripts) = pkg.scripts
1845    {
1846        all_script_names.extend(pkg_scripts.keys().cloned());
1847    }
1848    for (_, ws_pkg) in workspace_pkgs {
1849        if let Some(ref ws_scripts) = ws_pkg.scripts {
1850            all_script_names.extend(ws_scripts.keys().cloned());
1851        }
1852    }
1853    all_script_names
1854}
1855
1856/// Collect every directory (root and workspaces) that has a local `node_modules`.
1857fn collect_node_modules_roots<'a>(
1858    config: &'a ResolvedConfig,
1859    workspaces: &'a [fallow_config::WorkspaceInfo],
1860) -> Vec<&'a std::path::Path> {
1861    let mut nm_roots: Vec<&std::path::Path> = Vec::new();
1862    if config.root.join("node_modules").is_dir() {
1863        nm_roots.push(&config.root);
1864    }
1865    for ws in workspaces {
1866        if ws.root.join("node_modules").is_dir() {
1867            nm_roots.push(&ws.root);
1868        }
1869    }
1870    nm_roots
1871}
1872
1873/// Analyze the root package.json scripts and fold the results into the plugin result.
1874fn analyze_root_scripts(
1875    config: &ResolvedConfig,
1876    root_pkg: Option<&PackageJson>,
1877    bin_map: &rustc_hash::FxHashMap<String, String>,
1878    all_dep_set: &FxHashSet<String>,
1879    plugin_result: &mut plugins::AggregatedPluginResult,
1880) {
1881    let Some(pkg) = root_pkg else {
1882        return;
1883    };
1884    let Some(ref pkg_scripts) = pkg.scripts else {
1885        return;
1886    };
1887    let scripts_to_analyze = if config.production {
1888        scripts::filter_production_scripts(pkg_scripts)
1889    } else {
1890        pkg_scripts.clone()
1891    };
1892    let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1893    let script_analysis = scripts::analyze_scripts_with_dependency_context(
1894        &scripts_to_analyze,
1895        &config.root,
1896        bin_map,
1897        all_dep_set,
1898        &script_names,
1899    );
1900    plugin_result.script_used_packages = script_analysis.used_packages;
1901
1902    for config_file in &script_analysis.config_files {
1903        plugin_result
1904            .discovered_always_used
1905            .push((config_file.clone(), "scripts".to_string()));
1906    }
1907    for entry in &script_analysis.entry_files {
1908        if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1909            plugin_result
1910                .entry_patterns
1911                .push((plugins::PathRule::new(pat), "scripts".to_string()));
1912        }
1913    }
1914}
1915
1916/// Analyze each workspace package's scripts in parallel and merge the results.
1917type WsScriptOut = (
1918    Vec<String>,
1919    Vec<(String, String)>,
1920    Vec<(plugins::PathRule, String)>,
1921);
1922
1923fn analyze_workspace_scripts(
1924    config: &ResolvedConfig,
1925    workspace_pkgs: &[LoadedWorkspacePackage],
1926    bin_map: &rustc_hash::FxHashMap<String, String>,
1927    all_dep_set: &FxHashSet<String>,
1928    plugin_result: &mut plugins::AggregatedPluginResult,
1929) {
1930    let ws_results: Vec<WsScriptOut> = workspace_pkgs
1931        .par_iter()
1932        .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
1933        .collect();
1934    for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1935        plugin_result.script_used_packages.extend(used_packages);
1936        plugin_result
1937            .discovered_always_used
1938            .extend(discovered_always_used);
1939        plugin_result.entry_patterns.extend(entry_patterns);
1940    }
1941}
1942
1943/// Analyze a single workspace package's scripts, returning its used packages,
1944/// always-used config files, and entry patterns (all workspace-prefixed).
1945fn analyze_one_workspace_scripts(
1946    config: &ResolvedConfig,
1947    ws: &fallow_config::WorkspaceInfo,
1948    ws_pkg: &PackageJson,
1949    bin_map: &rustc_hash::FxHashMap<String, String>,
1950    all_dep_set: &FxHashSet<String>,
1951) -> WsScriptOut {
1952    let mut used_packages = Vec::new();
1953    let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1954    let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1955    let Some(ref ws_scripts) = ws_pkg.scripts else {
1956        return (used_packages, discovered_always_used, entry_patterns);
1957    };
1958    let scripts_to_analyze = if config.production {
1959        scripts::filter_production_scripts(ws_scripts)
1960    } else {
1961        ws_scripts.clone()
1962    };
1963    let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
1964    let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1965        &scripts_to_analyze,
1966        &ws.root,
1967        bin_map,
1968        all_dep_set,
1969        &script_names,
1970    );
1971    used_packages.extend(ws_analysis.used_packages);
1972
1973    let ws_prefix = ws
1974        .root
1975        .strip_prefix(&config.root)
1976        .unwrap_or(&ws.root)
1977        .to_string_lossy();
1978    for config_file in &ws_analysis.config_files {
1979        discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1980    }
1981    for entry in &ws_analysis.entry_files {
1982        if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1983            entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1984        }
1985    }
1986    (used_packages, discovered_always_used, entry_patterns)
1987}
1988
1989/// Analyze CI config files for binary invocations and merge the results.
1990fn analyze_ci_scripts(
1991    config: &ResolvedConfig,
1992    bin_map: &rustc_hash::FxHashMap<String, String>,
1993    all_dep_set: &FxHashSet<String>,
1994    all_script_names: &FxHashSet<String>,
1995    plugin_result: &mut plugins::AggregatedPluginResult,
1996) {
1997    let ci_analysis =
1998        scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_script_names);
1999    plugin_result
2000        .script_used_packages
2001        .extend(ci_analysis.used_packages);
2002    for entry in &ci_analysis.entry_files {
2003        if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2004            plugin_result
2005                .entry_patterns
2006                .push((plugins::PathRule::new(pat), "scripts".to_string()));
2007        }
2008    }
2009}
2010
2011/// Discover all entry points from static patterns, workspaces, plugins, and infrastructure.
2012fn discover_all_entry_points(
2013    config: &ResolvedConfig,
2014    files: &[discover::DiscoveredFile],
2015    workspaces: &[fallow_config::WorkspaceInfo],
2016    root_pkg: Option<&PackageJson>,
2017    workspace_pkgs: &[LoadedWorkspacePackage],
2018    plugin_result: &plugins::AggregatedPluginResult,
2019) -> discover::CategorizedEntryPoints {
2020    let mut entry_points = discover::CategorizedEntryPoints::default();
2021    let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
2022        config,
2023        files,
2024        root_pkg,
2025        workspaces.is_empty(),
2026    );
2027
2028    let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
2029        workspace_pkgs
2030            .iter()
2031            .map(|(ws, pkg)| (ws.root.clone(), pkg))
2032            .collect();
2033
2034    let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
2035        .par_iter()
2036        .map(|ws| {
2037            let pkg = workspace_pkg_by_root.get(&ws.root).copied();
2038            discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
2039        })
2040        .collect();
2041    let mut skipped_entries = rustc_hash::FxHashMap::default();
2042    entry_points.extend_runtime(root_discovery.entries);
2043    for (path, count) in root_discovery.skipped_entries {
2044        *skipped_entries.entry(path).or_insert(0) += count;
2045    }
2046    let mut ws_entries = Vec::new();
2047    for workspace in workspace_discovery {
2048        ws_entries.extend(workspace.entries);
2049        for (path, count) in workspace.skipped_entries {
2050            *skipped_entries.entry(path).or_insert(0) += count;
2051        }
2052    }
2053    discover::warn_skipped_entry_summary(&skipped_entries);
2054    entry_points.extend_runtime(ws_entries);
2055
2056    let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
2057    entry_points.extend(plugin_entries);
2058
2059    let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
2060    entry_points.extend_runtime(infra_entries);
2061
2062    if !config.dynamically_loaded.is_empty() {
2063        let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
2064        entry_points.extend_runtime(dynamic_entries);
2065    }
2066
2067    entry_points.dedup()
2068}
2069
2070/// Summarize entry points by source category for user-facing output.
2071fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
2072    let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
2073    for ep in entry_points {
2074        let category = match &ep.source {
2075            discover::EntryPointSource::PackageJsonMain
2076            | discover::EntryPointSource::PackageJsonModule
2077            | discover::EntryPointSource::PackageJsonExports
2078            | discover::EntryPointSource::PackageJsonBin
2079            | discover::EntryPointSource::PackageJsonScript => "package.json",
2080            discover::EntryPointSource::Plugin { .. } => "plugin",
2081            discover::EntryPointSource::TestFile => "test file",
2082            discover::EntryPointSource::DefaultIndex => "default index",
2083            discover::EntryPointSource::ManualEntry => "manual entry",
2084            discover::EntryPointSource::InfrastructureConfig => "config",
2085            discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
2086        };
2087        *counts.entry(category.to_string()).or_insert(0) += 1;
2088    }
2089    let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
2090    by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2091    results::EntryPointSummary {
2092        total: entry_points.len(),
2093        by_source,
2094    }
2095}
2096
2097fn append_package_file_asset_patterns(
2098    result: &mut plugins::AggregatedPluginResult,
2099    prefix: &str,
2100    pkg: &PackageJson,
2101) {
2102    let prefix = prefix.trim_matches('/');
2103    for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
2104        let pattern = if prefix.is_empty() {
2105            pattern
2106        } else {
2107            format!("{prefix}/{pattern}")
2108        };
2109        result
2110            .discovered_always_used
2111            .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
2112    }
2113}
2114
2115fn append_workspace_package_file_asset_patterns(
2116    result: &mut plugins::AggregatedPluginResult,
2117    config: &ResolvedConfig,
2118    workspace_pkgs: &[LoadedWorkspacePackage],
2119) {
2120    for (ws, ws_pkg) in workspace_pkgs {
2121        let ws_prefix = ws
2122            .root
2123            .strip_prefix(&config.root)
2124            .unwrap_or(&ws.root)
2125            .to_string_lossy()
2126            .replace('\\', "/");
2127        append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
2128    }
2129}
2130
2131/// Run plugins for root project and all workspace packages.
2132fn run_plugins(
2133    config: &ResolvedConfig,
2134    files: &[discover::DiscoveredFile],
2135    workspaces: &[fallow_config::WorkspaceInfo],
2136    root_pkg: Option<&PackageJson>,
2137    workspace_pkgs: &[LoadedWorkspacePackage],
2138    config_candidates: &[std::path::PathBuf],
2139) -> Result<plugins::AggregatedPluginResult, FallowError> {
2140    let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2141    let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
2142
2143    // The non-production config-discovery fast path: resolve plugin config
2144    // patterns against the files the discovery walk already collected (source
2145    // files unioned with non-source config candidates) instead of re-walking the
2146    // filesystem. Production keeps the filesystem path (no candidates captured).
2147    let candidate_index = (!config.production).then(|| {
2148        plugins::registry::ConfigCandidateIndex::build(
2149            file_paths
2150                .iter()
2151                .map(std::path::PathBuf::as_path)
2152                .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
2153        )
2154    });
2155
2156    let mut result = run_root_plugins(
2157        &registry,
2158        config,
2159        root_pkg,
2160        &file_paths,
2161        candidate_index.as_ref(),
2162    )?;
2163
2164    if workspaces.is_empty() {
2165        gate_auto_import_entry_patterns(&mut result, config, workspaces);
2166        return Ok(result);
2167    }
2168
2169    append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
2170
2171    let ws_results = run_workspace_plugins(
2172        &registry,
2173        config,
2174        workspace_pkgs,
2175        &file_paths,
2176        &result.active_plugins,
2177        candidate_index.as_ref(),
2178    );
2179    merge_workspace_plugin_results(&mut result, ws_results)?;
2180
2181    gate_auto_import_entry_patterns(&mut result, config, workspaces);
2182
2183    Ok(result)
2184}
2185
2186type WorkspacePluginResult = Result<
2187    (plugins::AggregatedPluginResult, String),
2188    Vec<plugins::registry::PluginRegexValidationError>,
2189>;
2190
2191/// Run plugins for the root project and apply its package-file asset patterns.
2192fn run_root_plugins(
2193    registry: &plugins::PluginRegistry,
2194    config: &ResolvedConfig,
2195    root_pkg: Option<&PackageJson>,
2196    file_paths: &[std::path::PathBuf],
2197    candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2198) -> Result<plugins::AggregatedPluginResult, FallowError> {
2199    let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
2200    let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
2201        .iter()
2202        .map(std::path::PathBuf::as_path)
2203        .collect();
2204
2205    let mut result = if let Some(pkg) = root_pkg {
2206        registry
2207            .try_run_with_search_roots(
2208                pkg,
2209                &config.root,
2210                file_paths,
2211                &root_config_search_root_refs,
2212                config.production,
2213                candidate_index,
2214            )
2215            .map_err(|errors| {
2216                FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
2217            })?
2218    } else {
2219        plugins::AggregatedPluginResult::default()
2220    };
2221    if let Some(pkg) = root_pkg {
2222        append_package_file_asset_patterns(&mut result, "", pkg);
2223    }
2224    Ok(result)
2225}
2226
2227/// Run plugins for every workspace package in parallel, returning per-workspace
2228/// results (or regex errors) for the caller to merge.
2229fn run_workspace_plugins(
2230    registry: &plugins::PluginRegistry,
2231    config: &ResolvedConfig,
2232    workspace_pkgs: &[LoadedWorkspacePackage],
2233    file_paths: &[std::path::PathBuf],
2234    root_active_plugins: &[String],
2235    candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2236) -> Vec<WorkspacePluginResult> {
2237    let root_active_plugins: rustc_hash::FxHashSet<&str> =
2238        root_active_plugins.iter().map(String::as_str).collect();
2239
2240    let precompiled_matchers = registry.precompile_config_matchers();
2241    let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
2242
2243    workspace_pkgs
2244        .par_iter()
2245        .zip(workspace_relative_files.par_iter())
2246        .filter_map(|((ws, ws_pkg), relative_files)| {
2247            let ws_result =
2248                match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
2249                    pkg: ws_pkg,
2250                    root: &ws.root,
2251                    project_root: &config.root,
2252                    precompiled_config_matchers: &precompiled_matchers,
2253                    relative_files,
2254                    skip_config_plugins: &root_active_plugins,
2255                    production_mode: config.production,
2256                    candidate_index,
2257                }) {
2258                    Ok(result) => result,
2259                    Err(errors) => return Some(Err(errors)),
2260                };
2261            if ws_result.active_plugins.is_empty() {
2262                return None;
2263            }
2264            let ws_prefix = ws
2265                .root
2266                .strip_prefix(&config.root)
2267                .unwrap_or(&ws.root)
2268                .to_string_lossy()
2269                .into_owned();
2270            Some(Ok((ws_result, ws_prefix)))
2271        })
2272        .collect::<Vec<_>>()
2273}
2274
2275/// Merge per-workspace plugin results into the root result, surfacing any
2276/// accumulated regex errors as a single config error.
2277fn merge_workspace_plugin_results(
2278    result: &mut plugins::AggregatedPluginResult,
2279    ws_results: Vec<WorkspacePluginResult>,
2280) -> Result<(), FallowError> {
2281    let mut regex_errors = Vec::new();
2282    for ws_result in ws_results {
2283        match ws_result {
2284            Ok((mut ws_result, ws_prefix)) => {
2285                ws_result.apply_workspace_prefix(&ws_prefix);
2286                ws_result.config_patterns.clear();
2287                ws_result.script_used_packages.clear();
2288                result.merge_into(ws_result);
2289            }
2290            Err(mut errors) => regex_errors.append(&mut errors),
2291        }
2292    }
2293    if !regex_errors.is_empty() {
2294        return Err(FallowError::config(
2295            plugins::registry::format_plugin_regex_errors(&regex_errors),
2296        ));
2297    }
2298    Ok(())
2299}
2300
2301/// When `autoImports` is enabled, drop the modeled Nuxt convention entry
2302/// patterns so genuinely-unreferenced convention files are reported as
2303/// `unused-file`. Component and script fallbacks have separate conservative
2304/// config guards because custom `components:` and `imports:` settings affect
2305/// different convention surfaces.
2306fn gate_auto_import_entry_patterns(
2307    result: &mut plugins::AggregatedPluginResult,
2308    config: &ResolvedConfig,
2309    workspaces: &[fallow_config::WorkspaceInfo],
2310) {
2311    if !config.auto_imports {
2312        return;
2313    }
2314    if !result.active_plugins.iter().any(|name| name == "nuxt") {
2315        return;
2316    }
2317    let components_custom = plugins::nuxt::config_declares_components(&config.root)
2318        || workspaces
2319            .iter()
2320            .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
2321    let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
2322        || workspaces
2323            .iter()
2324            .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
2325    result.entry_patterns.retain(|(rule, plugin)| {
2326        if plugin != "nuxt" {
2327            return true;
2328        }
2329        if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2330            return false;
2331        }
2332        if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2333            return false;
2334        }
2335        true
2336    });
2337}
2338
2339fn bucket_files_by_workspace(
2340    workspace_pkgs: &[LoadedWorkspacePackage],
2341    file_paths: &[std::path::PathBuf],
2342) -> Vec<Vec<(std::path::PathBuf, String)>> {
2343    use rayon::prelude::*;
2344
2345    // Assign each file to its first matching workspace in parallel. On large
2346    // monorepos this is O(files x workspaces) prefix scans plus a path clone and
2347    // a relative-path allocation per file; doing it per file on one thread was a
2348    // measurable slice of the plugins stage. The assignment is independent per
2349    // file, so the only ordering contract to preserve is first-match-by-workspace-
2350    // declaration-order (the original `break`) and per-bucket file order, both of
2351    // which hold because the parallel map keeps file indexing and the bucket fill
2352    // below walks the assignments in original file order.
2353    let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2354        .par_iter()
2355        .map(|file_path| {
2356            workspace_pkgs
2357                .iter()
2358                .enumerate()
2359                .find_map(|(idx, (ws, _))| {
2360                    file_path.strip_prefix(&ws.root).ok().map(|relative| {
2361                        (
2362                            idx,
2363                            file_path.clone(),
2364                            relative.to_string_lossy().into_owned(),
2365                        )
2366                    })
2367                })
2368        })
2369        .collect();
2370
2371    let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
2372    for (idx, file_path, relative) in assignments.into_iter().flatten() {
2373        buckets[idx].push((file_path, relative));
2374    }
2375
2376    buckets
2377}
2378
2379fn collect_config_search_roots(
2380    root: &Path,
2381    file_paths: &[std::path::PathBuf],
2382) -> Vec<std::path::PathBuf> {
2383    let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2384    roots.insert(root.to_path_buf());
2385
2386    for file_path in file_paths {
2387        let mut current = file_path.parent();
2388        while let Some(dir) = current {
2389            if !dir.starts_with(root) {
2390                break;
2391            }
2392            roots.insert(dir.to_path_buf());
2393            if dir == root {
2394                break;
2395            }
2396            current = dir.parent();
2397        }
2398    }
2399
2400    let mut roots_vec: Vec<_> = roots.into_iter().collect();
2401    roots_vec.sort();
2402    roots_vec
2403}
2404
2405/// Resolve the analysis config for a project, mirroring the CLI's `--config`
2406/// behavior when `config_path` is provided.
2407///
2408/// # Errors
2409///
2410/// Returns an error when an explicit config cannot be loaded or automatic
2411/// config discovery finds an invalid config.
2412pub(crate) fn config_for_project(
2413    root: &Path,
2414    config_path: Option<&Path>,
2415) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2416    let user_config = if let Some(path) = config_path {
2417        Some((
2418            fallow_config::FallowConfig::load(path)
2419                .map_err(|e| FallowError::config(format!("{e:#}")))?,
2420            path.to_path_buf(),
2421        ))
2422    } else {
2423        fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2424    };
2425
2426    let config = match user_config {
2427        Some((config, path)) => resolve_user_config(config, path, root)?,
2428        None => (
2429            fallow_config::FallowConfig::default().resolve(
2430                root.to_path_buf(),
2431                fallow_config::OutputFormat::Human,
2432                num_cpus(),
2433                false,
2434                true,
2435                None,
2436            ),
2437            None,
2438        ),
2439    };
2440
2441    Ok(config)
2442}
2443
2444/// Flatten the dead-code production flag, validate boundaries and rule packs,
2445/// then resolve a user-supplied config for LSP/programmatic callers.
2446fn resolve_user_config(
2447    mut config: fallow_config::FallowConfig,
2448    path: std::path::PathBuf,
2449    root: &Path,
2450) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2451    let dead_code_production = config
2452        .production
2453        .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2454    config.production = dead_code_production.into();
2455    config
2456        .validate_resolved_boundaries(root)
2457        .map_err(|errors| {
2458            let joined = errors
2459                .iter()
2460                .map(ToString::to_string)
2461                .collect::<Vec<_>>()
2462                .join("\n  - ");
2463            FallowError::config(format!("invalid boundary configuration:\n  - {joined}"))
2464        })?;
2465    let packs = fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
2466        let joined = errors
2467            .iter()
2468            .map(ToString::to_string)
2469            .collect::<Vec<_>>()
2470            .join("\n  - ");
2471        FallowError::config(format!("invalid rule pack:\n  - {joined}"))
2472    })?;
2473    let boundaries =
2474        fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
2475    let zone_errors = fallow_config::validate_rule_pack_zone_references(
2476        root,
2477        &config.rule_packs,
2478        &packs,
2479        &boundaries,
2480    );
2481    if !zone_errors.is_empty() {
2482        let joined = zone_errors
2483            .iter()
2484            .map(ToString::to_string)
2485            .collect::<Vec<_>>()
2486            .join("\n  - ");
2487        return Err(FallowError::config(format!(
2488            "invalid rule pack:\n  - {joined}"
2489        )));
2490    }
2491    Ok((
2492        config.resolve(
2493            root.to_path_buf(),
2494            fallow_config::OutputFormat::Human,
2495            num_cpus(),
2496            false,
2497            true, // quiet: LSP/programmatic callers don't need progress bars
2498            None, // LSP/programmatic embedders use the default cache cap
2499        ),
2500        Some(path),
2501    ))
2502}
2503
2504/// Create a default config for a project root.
2505///
2506/// `analyze_project` is the dead-code entry point used by the LSP and other
2507/// programmatic embedders. When the loaded config uses the per-analysis
2508/// production form (`production: { deadCode: true, ... }`), the production
2509/// flag must be flattened to the dead-code analysis here. Otherwise
2510/// `ResolvedConfig::resolve` calls `.global()` which returns false for the
2511/// per-analysis variant and the production-mode rule overrides
2512/// (`unused_dev_dependencies: off`, etc.) plus `resolved.production = true`
2513/// are silently dropped.
2514#[cfg_attr(
2515    not(test),
2516    allow(
2517        dead_code,
2518        reason = "config resolution fallback is exercised by session tests"
2519    )
2520)]
2521pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
2522    config_for_project(root, None).map_or_else(
2523        |_| {
2524            fallow_config::FallowConfig::default().resolve(
2525                root.to_path_buf(),
2526                fallow_config::OutputFormat::Human,
2527                num_cpus(),
2528                false,
2529                true,
2530                None,
2531            )
2532        },
2533        |(config, _)| config,
2534    )
2535}
2536
2537fn num_cpus() -> usize {
2538    std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
2539}
2540
2541#[cfg(test)]
2542mod tests {
2543    use super::{
2544        AnalysisSession, bucket_files_by_workspace, collect_config_search_roots, default_config,
2545        format_undeclared_workspace_warning, parse_analysis_modules, plugin_config_hash,
2546        resolver_options_hash, warn_undeclared_workspaces,
2547    };
2548    use std::path::{Path, PathBuf};
2549    use std::time::Instant;
2550
2551    use fallow_config::{
2552        AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
2553    };
2554    use fallow_types::discover::{DiscoveredFile, FileId};
2555
2556    fn plugin_result() -> crate::plugins::AggregatedPluginResult {
2557        let mut result = crate::plugins::AggregatedPluginResult::default();
2558        result.active_plugins.push("nuxt".to_string());
2559        result
2560            .path_aliases
2561            .push(("@/".to_string(), "src/".to_string()));
2562        result
2563    }
2564
2565    #[test]
2566    fn graph_cache_resolver_hash_includes_project_root() {
2567        let dir_a = tempfile::tempdir().expect("create temp dir a");
2568        let dir_b = tempfile::tempdir().expect("create temp dir b");
2569        let config_a = session_config(dir_a.path());
2570        let config_b = session_config(dir_b.path());
2571
2572        assert_ne!(
2573            resolver_options_hash(&config_a),
2574            resolver_options_hash(&config_b),
2575            "shared cache dirs must not reuse graphs across project roots"
2576        );
2577    }
2578
2579    #[test]
2580    fn graph_cache_resolver_hash_includes_resolve_conditions() {
2581        let dir = tempfile::tempdir().expect("create temp dir");
2582        let config_a = session_config(dir.path());
2583        let mut config_b = session_config(dir.path());
2584        config_b.resolve.conditions.push("react-server".to_string());
2585
2586        assert_ne!(
2587            resolver_options_hash(&config_a),
2588            resolver_options_hash(&config_b),
2589            "resolve condition changes must invalidate the graph cache"
2590        );
2591    }
2592
2593    #[test]
2594    fn graph_cache_plugin_hash_includes_auto_imports() {
2595        let mut without_auto_import = plugin_result();
2596        let mut with_auto_import = plugin_result();
2597        with_auto_import.auto_imports.push(AutoImportRule {
2598            name: "useCounter".to_string(),
2599            source: PathBuf::from("/project/composables/useCounter.ts"),
2600            kind: AutoImportKind::Named,
2601        });
2602
2603        assert_ne!(
2604            plugin_config_hash(&without_auto_import),
2605            plugin_config_hash(&with_auto_import),
2606            "auto-import edge changes must invalidate the graph cache"
2607        );
2608
2609        without_auto_import.auto_imports.push(AutoImportRule {
2610            name: "useCounter".to_string(),
2611            source: PathBuf::from("/project/composables/useCounter.ts"),
2612            kind: AutoImportKind::Default,
2613        });
2614        assert_ne!(
2615            plugin_config_hash(&without_auto_import),
2616            plugin_config_hash(&with_auto_import),
2617            "auto-import kind changes must invalidate the graph cache"
2618        );
2619    }
2620
2621    #[test]
2622    fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
2623        let base = plugin_result();
2624        let mut with_scss = base.clone();
2625        with_scss
2626            .scss_include_paths
2627            .push(PathBuf::from("/project/styles"));
2628        assert_ne!(
2629            plugin_config_hash(&base),
2630            plugin_config_hash(&with_scss),
2631            "SCSS include path changes must invalidate the graph cache"
2632        );
2633
2634        let mut with_static_dir = base.clone();
2635        with_static_dir
2636            .static_dir_mappings
2637            .push((PathBuf::from("/project/public"), "/".to_string()));
2638        assert_ne!(
2639            plugin_config_hash(&base),
2640            plugin_config_hash(&with_static_dir),
2641            "static directory mapping changes must invalidate the graph cache"
2642        );
2643    }
2644
2645    fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
2646        WorkspaceDiagnostic::new(
2647            root,
2648            root.join(relative),
2649            WorkspaceDiagnosticKind::UndeclaredWorkspace,
2650        )
2651    }
2652
2653    fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
2654        let mut config = default_config(root);
2655        config.no_cache = true;
2656        config.quiet = true;
2657        config
2658    }
2659
2660    fn write_session_fixture(root: &Path) {
2661        let src = root.join("src");
2662        std::fs::create_dir_all(&src).expect("create src");
2663        std::fs::write(
2664            root.join("package.json"),
2665            r#"{"name":"session-fixture","type":"module"}"#,
2666        )
2667        .expect("write package json");
2668        std::fs::write(
2669            src.join("index.ts"),
2670            "import { used } from './used';\nconsole.log(used);\n",
2671        )
2672        .expect("write index");
2673        std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
2674    }
2675
2676    #[test]
2677    fn analysis_session_discovers_project_files() {
2678        let dir = tempfile::tempdir().expect("create temp dir");
2679        write_session_fixture(dir.path());
2680        let config = session_config(dir.path());
2681
2682        let session = AnalysisSession::new(&config);
2683
2684        assert!(
2685            session
2686                .files()
2687                .iter()
2688                .any(|file| file.path.ends_with("src/index.ts")),
2689            "session should own discovered project files"
2690        );
2691        assert_eq!(session.workspaces().len(), 0);
2692    }
2693
2694    #[test]
2695    fn direct_core_parse_surfaces_source_read_failure_diagnostic() {
2696        let project = tempfile::tempdir().expect("create project");
2697        let root = project.path();
2698        let paths = ["a.ts", "b.ts", "c.ts"].map(|name| root.join(name));
2699        for (index, path) in paths.iter().enumerate() {
2700            std::fs::write(path, format!("export const value{index} = {index};\n"))
2701                .expect("write source");
2702        }
2703        let files: Vec<DiscoveredFile> = paths
2704            .iter()
2705            .enumerate()
2706            .map(|(index, path)| DiscoveredFile {
2707                id: FileId(u32::try_from(index).expect("test index fits u32")),
2708                path: path.clone(),
2709                size_bytes: std::fs::metadata(path).expect("source metadata").len(),
2710            })
2711            .collect();
2712        std::fs::remove_file(&paths[1]).expect("remove source after discovery");
2713        let config = session_config(root);
2714
2715        let parsed = parse_analysis_modules(&config, &files, false, Instant::now());
2716
2717        assert_eq!(
2718            parsed
2719                .modules
2720                .iter()
2721                .map(|module| module.file_id)
2722                .collect::<Vec<_>>(),
2723            vec![FileId(0), FileId(2)]
2724        );
2725        let diagnostics = fallow_config::workspace_diagnostics_for(root);
2726        let diagnostic = diagnostics
2727            .iter()
2728            .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2729            .expect("source read failure diagnostic");
2730        assert_eq!(diagnostic.path, paths[1]);
2731        assert!(matches!(
2732            diagnostic.kind,
2733            WorkspaceDiagnosticKind::SourceReadFailure { .. }
2734        ));
2735    }
2736
2737    #[test]
2738    fn analysis_session_parses_owned_modules() {
2739        let dir = tempfile::tempdir().expect("create temp dir");
2740        write_session_fixture(dir.path());
2741        let config = session_config(dir.path());
2742
2743        let session = AnalysisSession::new(&config);
2744        let parsed = session.parse_modules(false);
2745
2746        assert!(
2747            parsed
2748                .modules
2749                .iter()
2750                .any(|module| session.files()[module.file_id.0 as usize]
2751                    .path
2752                    .ends_with("src/index.ts")),
2753            "session parsing should return modules keyed to session files"
2754        );
2755    }
2756
2757    #[test]
2758    fn undeclared_workspace_warning_is_singular_for_one_path() {
2759        let root = Path::new("/repo");
2760        let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
2761            .expect("warning should be rendered");
2762
2763        assert_eq!(
2764            warning,
2765            "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."
2766        );
2767    }
2768
2769    #[test]
2770    fn undeclared_workspace_warning_summarizes_many_paths() {
2771        let root = PathBuf::from("/repo");
2772        let diagnostics = [
2773            "examples/a",
2774            "examples/b",
2775            "examples/c",
2776            "examples/d",
2777            "examples/e",
2778            "examples/f",
2779        ]
2780        .into_iter()
2781        .map(|path| diag(&root, path))
2782        .collect::<Vec<_>>();
2783
2784        let warning = format_undeclared_workspace_warning(&root, &diagnostics)
2785            .expect("warning should be rendered");
2786
2787        assert_eq!(
2788            warning,
2789            "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."
2790        );
2791    }
2792
2793    #[test]
2794    fn collect_config_search_roots_includes_file_ancestors_once() {
2795        let root = PathBuf::from("/repo");
2796        let search_roots = collect_config_search_roots(
2797            &root,
2798            &[
2799                root.join("apps/query/src/main.ts"),
2800                root.join("packages/shared/lib/index.ts"),
2801            ],
2802        );
2803
2804        assert_eq!(
2805            search_roots,
2806            vec![
2807                root.clone(),
2808                root.join("apps"),
2809                root.join("apps/query"),
2810                root.join("apps/query/src"),
2811                root.join("packages"),
2812                root.join("packages/shared"),
2813                root.join("packages/shared/lib"),
2814            ]
2815        );
2816    }
2817
2818    #[test]
2819    fn bucket_files_by_workspace_uses_workspace_relative_paths() {
2820        let root = PathBuf::from("/repo");
2821        let ui = fallow_config::WorkspaceInfo {
2822            root: root.join("apps/ui"),
2823            name: "ui".to_string(),
2824            is_internal_dependency: false,
2825        };
2826        let api = fallow_config::WorkspaceInfo {
2827            root: root.join("apps/api"),
2828            name: "api".to_string(),
2829            is_internal_dependency: false,
2830        };
2831        let workspace_pkgs = vec![
2832            (
2833                ui,
2834                fallow_config::PackageJson {
2835                    name: Some("ui".to_string()),
2836                    ..Default::default()
2837                },
2838            ),
2839            (
2840                api,
2841                fallow_config::PackageJson {
2842                    name: Some("api".to_string()),
2843                    ..Default::default()
2844                },
2845            ),
2846        ];
2847        let files = vec![
2848            root.join("apps/ui/vite.config.ts"),
2849            root.join("apps/ui/src/main.ts"),
2850            root.join("apps/api/src/server.ts"),
2851            root.join("tools/build.ts"),
2852        ];
2853
2854        let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
2855
2856        assert_eq!(
2857            buckets[0],
2858            vec![
2859                (
2860                    root.join("apps/ui/vite.config.ts"),
2861                    "vite.config.ts".to_string()
2862                ),
2863                (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
2864            ]
2865        );
2866        assert_eq!(
2867            buckets[1],
2868            vec![(
2869                root.join("apps/api/src/server.ts"),
2870                "src/server.ts".to_string()
2871            )]
2872        );
2873    }
2874
2875    #[test]
2876    fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
2877        let dir = tempfile::tempdir().expect("create temp dir");
2878        let pkg_good = dir.path().join("packages").join("good");
2879        let pkg_bad = dir.path().join("packages").join("bad");
2880        std::fs::create_dir_all(&pkg_good).unwrap();
2881        std::fs::create_dir_all(&pkg_bad).unwrap();
2882        std::fs::write(
2883            dir.path().join("package.json"),
2884            r#"{"workspaces": ["packages/*"]}"#,
2885        )
2886        .unwrap();
2887        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2888        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2889
2890        let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
2891            dir.path(),
2892            &globset::GlobSet::empty(),
2893        )
2894        .expect("root package.json is valid");
2895        assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
2896        fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
2897
2898        warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
2899
2900        let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2901        let mut malformed = 0;
2902        let mut undeclared_for_bad = 0;
2903        for diag in &diagnostics {
2904            if matches!(
2905                diag.kind,
2906                WorkspaceDiagnosticKind::MalformedPackageJson { .. }
2907            ) && diag.path.ends_with("bad")
2908            {
2909                malformed += 1;
2910            }
2911            if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
2912                && diag.path.ends_with("bad")
2913            {
2914                undeclared_for_bad += 1;
2915            }
2916        }
2917        assert_eq!(
2918            malformed, 1,
2919            "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
2920        );
2921        assert_eq!(
2922            undeclared_for_bad, 0,
2923            "warn_undeclared_workspaces must NOT re-flag a path that already \
2924             carries MalformedPackageJson; got duplicates: {diagnostics:?}"
2925        );
2926    }
2927}