Skip to main content

fallow_core/
lib.rs

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