Skip to main content

fallow_core/
lib.rs

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