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