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