Skip to main content

fallow_engine/
session.rs

1//! Engine-owned analysis session orchestration.
2
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::Instant;
6
7use fallow_config::{DuplicatesConfig, ResolvedConfig, WorkspaceInfo};
8use fallow_types::discover::DiscoveredFile;
9use fallow_types::extract::ModuleInfo;
10#[cfg(test)]
11use fallow_types::results::AnalysisResults;
12use fallow_types::source_fingerprint::SourceFingerprint;
13use fallow_types::workspace::{WorkspaceDiagnostic, merge_workspace_diagnostics};
14use rustc_hash::{FxHashMap, FxHashSet};
15
16use crate::{
17    EngineResult, core_backend, duplicates,
18    project_analysis::{
19        ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
20    },
21    project_config::{ProjectConfig, config_for_project, default_project_config},
22    results::{
23        DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
24        SharedDeadCodeAnalysisArtifacts,
25    },
26};
27
28/// Reusable engine session for one resolved project.
29///
30/// The session owns the resolved config and discovered file set so future
31/// consumers can share graph-sensitive inputs without each surface recreating
32/// its own partial orchestration.
33#[derive(Debug)]
34pub struct AnalysisSession {
35    config: ResolvedConfig,
36    config_path: Option<PathBuf>,
37    discovery: crate::discover::AnalysisDiscovery,
38    workspaces: Vec<WorkspaceInfo>,
39    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
40    parsed_cache: Mutex<Option<ParsedModuleCache>>,
41    styling_cache: Mutex<Option<Arc<crate::health::StylingAnalysisArtifacts>>>,
42}
43
44#[derive(Debug)]
45struct ParsedModuleCache {
46    need_complexity: bool,
47    fingerprints: Vec<SourceFingerprint>,
48    modules: Arc<[ModuleInfo]>,
49}
50
51/// Owned session parts for runners that need to continue an existing pipeline.
52#[derive(Debug)]
53pub struct AnalysisSessionParts {
54    /// Resolved project config the session was created with.
55    pub config: ResolvedConfig,
56    /// Path of the loaded config file; `None` when defaults were used.
57    pub config_path: Option<PathBuf>,
58    /// Files discovered under the session root.
59    pub files: Vec<DiscoveredFile>,
60    /// Workspace metadata discovered during config resolution.
61    pub workspaces: Vec<WorkspaceInfo>,
62    /// Diagnostics from workspace discovery (undeclared or invalid members).
63    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
64}
65
66/// Owned session parts after parsing the discovered files.
67#[derive(Debug)]
68pub struct ParsedAnalysisSessionParts {
69    /// Resolved project config the session was created with.
70    pub config: ResolvedConfig,
71    /// Path of the loaded config file; `None` when defaults were used.
72    pub config_path: Option<PathBuf>,
73    /// Files discovered under the session root.
74    pub files: Vec<DiscoveredFile>,
75    /// Parsed modules, one per discovered file.
76    pub modules: Vec<ModuleInfo>,
77    /// Workspace metadata discovered during config resolution.
78    pub workspaces: Vec<WorkspaceInfo>,
79    /// Diagnostics from workspace discovery (undeclared or invalid members).
80    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
81    /// Parse wall time in milliseconds.
82    pub parse_ms: f64,
83    /// Parse-cache write-back wall time in milliseconds.
84    pub cache_update_ms: f64,
85    /// Files served from the parse cache.
86    pub cache_hits: usize,
87    /// Files that had to be parsed fresh.
88    pub cache_misses: usize,
89    /// Summed parse CPU time across rayon workers in milliseconds.
90    pub parse_cpu_ms: f64,
91}
92
93#[derive(Debug)]
94pub(crate) struct SharedParsedAnalysisSessionParts {
95    pub(crate) config: ResolvedConfig,
96    pub(crate) files: Vec<DiscoveredFile>,
97    pub(crate) modules: Arc<[ModuleInfo]>,
98    pub workspaces: Vec<WorkspaceInfo>,
99    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
100    pub parse_ms: f64,
101    pub parse_cpu_ms: f64,
102}
103
104/// Reusable artifacts produced by one session-owned dead-code run.
105#[derive(Debug)]
106pub struct AnalysisSessionArtifacts {
107    /// Retained dead-code analysis output (results, graph, timings).
108    pub analysis: DeadCodeAnalysisArtifacts,
109    /// Diff scope the run was limited to, when one was resolved.
110    pub changed_files: Option<FxHashSet<PathBuf>>,
111    /// Per-file source fingerprints for downstream cache invalidation.
112    pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
113}
114
115impl AnalysisSession {
116    /// Load config and discover files for a project root.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when config loading fails.
121    pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
122        let project_config = config_for_project(root, config_path)?;
123        Ok(Self::from_config(project_config))
124    }
125
126    /// Load config, apply one caller-supplied config adjustment, then discover
127    /// files for a project root.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error when config loading fails.
132    pub fn load_with_config(
133        root: &Path,
134        config_path: Option<&Path>,
135        configure: impl FnOnce(&mut ResolvedConfig),
136    ) -> EngineResult<Self> {
137        Self::load_with_config_options(
138            root,
139            config_path,
140            fallow_config::ConfigLoadOptions::default(),
141            configure,
142        )
143    }
144
145    /// Load config with an explicit inheritance trust policy, apply one
146    /// caller-supplied adjustment, then discover project files.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error when config loading fails.
151    pub fn load_with_config_options(
152        root: &Path,
153        config_path: Option<&Path>,
154        load_options: fallow_config::ConfigLoadOptions,
155        configure: impl FnOnce(&mut ResolvedConfig),
156    ) -> EngineResult<Self> {
157        let mut project_config = crate::project_config::config_for_project_with_load_options(
158            root,
159            config_path,
160            load_options,
161        )?;
162        configure(&mut project_config.config);
163        project_config.workspaces.clear();
164        project_config.workspace_diagnostics.clear();
165        project_config.workspace_discovery_ms = None;
166        Ok(Self::from_config(project_config))
167    }
168
169    /// Build a session from built-in defaults, ignoring project config files.
170    ///
171    /// This is intended for editor fallback paths that have already reported a
172    /// config-load warning but should still surface best-effort diagnostics.
173    #[must_use]
174    pub fn load_default(root: &Path) -> Self {
175        Self::from_config(default_project_config(root))
176    }
177
178    /// Build a session from a previously resolved config.
179    #[must_use]
180    pub fn from_config(project_config: ProjectConfig) -> Self {
181        let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
182        let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
183        {
184            crate::discover::prepare_analysis_discovery_with_workspaces(
185                &project_config.config,
186                &project_config.workspaces,
187                workspace_discovery_ms,
188            )
189        } else {
190            crate::discover::prepare_analysis_discovery(&project_config.config)
191        };
192        let workspaces = if uses_preloaded_workspaces {
193            project_config.workspaces
194        } else {
195            discovery.workspaces().to_vec()
196        };
197        // Analysis-stage diagnostics are owned by the analyze pass, which
198        // refreshes the registry on every run; pinning them in the session
199        // snapshot would keep a stale entry alive after the cause is fixed
200        // (issue #2366). `current_workspace_diagnostics` reads them live.
201        //
202        // Source-discovery entries come from THIS walk's return value, not from
203        // the registry: combined mode runs the dead-code and duplication walks
204        // concurrently whenever a per-analysis `production` split stops them
205        // from sharing a file list, and each walk replaces the registry's
206        // source-discovery set for the root, so a registry read here would
207        // report whichever walk happened to write last (issue #2366).
208        let workspace_diagnostics = merge_workspace_diagnostics(
209            merge_workspace_diagnostics(
210                project_config.workspace_diagnostics,
211                fallow_config::workspace_diagnostics_for(&project_config.config.root)
212                    .into_iter()
213                    .filter(|diagnostic| {
214                        !diagnostic.kind.is_analysis_stage()
215                            && !diagnostic.kind.is_source_discovery()
216                    })
217                    .collect(),
218            ),
219            discovery.source_diagnostics().to_vec(),
220        );
221        Self {
222            config: project_config.config,
223            config_path: project_config.path,
224            discovery,
225            workspaces,
226            workspace_diagnostics,
227            parsed_cache: Mutex::new(None),
228            styling_cache: Mutex::new(None),
229        }
230    }
231
232    /// Build a session from a resolved config when the caller already owns
233    /// command-specific config loading.
234    ///
235    /// # Errors
236    ///
237    /// Returns an engine error when root manifest loading fails during
238    /// workspace discovery, matching `ProjectConfig::load`.
239    pub fn from_resolved_config(config: ResolvedConfig) -> EngineResult<Self> {
240        let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
241            crate::project_config::collect_workspace_metadata(&config)?;
242        Ok(Self::from_config(ProjectConfig {
243            config,
244            path: None,
245            workspaces,
246            workspace_diagnostics,
247            workspace_discovery_ms: Some(workspace_discovery_ms),
248        }))
249    }
250
251    /// Resolved project root.
252    #[must_use]
253    pub fn root(&self) -> &Path {
254        &self.config.root
255    }
256
257    /// Resolved project config.
258    #[must_use]
259    pub fn config(&self) -> &ResolvedConfig {
260        &self.config
261    }
262
263    /// Config file path when one was loaded.
264    #[must_use]
265    pub fn config_path(&self) -> Option<&Path> {
266        self.config_path.as_deref()
267    }
268
269    /// Discovered files for this session.
270    #[must_use]
271    pub fn files(&self) -> &[DiscoveredFile] {
272        self.discovery.files()
273    }
274
275    /// Workspace packages discovered during config/session setup.
276    #[must_use]
277    pub fn workspaces(&self) -> &[WorkspaceInfo] {
278        &self.workspaces
279    }
280
281    /// Source metadata fingerprints for every discovered source file.
282    #[must_use]
283    fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
284        self.discovery
285            .files()
286            .iter()
287            .map(|file| {
288                let fingerprint = std::fs::metadata(&file.path).map_or_else(
289                    |_| SourceFingerprint::new(0, file.size_bytes),
290                    |metadata| SourceFingerprint::from_metadata(&metadata),
291                );
292                (file.path.clone(), fingerprint)
293            })
294            .collect()
295    }
296
297    /// Resolve files changed since a git ref against this session root.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error when the ref is invalid, git is unavailable, or the
302    /// root is not part of a repository.
303    pub(crate) fn changed_files_since(
304        &self,
305        git_ref: &str,
306    ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
307        crate::changed_files::changed_files(&self.config.root, git_ref)
308    }
309
310    /// Workspace and source-discovery diagnostics captured for this session.
311    #[must_use]
312    pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
313        &self.workspace_diagnostics
314    }
315
316    /// Current diagnostics, including the source read failures the parse stage
317    /// discovers and the analysis-stage entries the analyze pass records, both
318    /// of which land in the registry after the session was created.
319    ///
320    /// The live read goes through
321    /// [`fallow_config::registry_diagnostics_to_fold`], which drops
322    /// walk-recorded entries for the same reason the constructor does: a
323    /// concurrent walk on the same root replaces that set, so importing it here
324    /// would make this session's list depend on which walk wrote last, and the
325    /// combined root's union would come out in a different ORDER between runs
326    /// of the same command (issue #2366). This session's own walk-recorded
327    /// entries are already in the snapshot, by value, from its own walk.
328    #[must_use]
329    pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
330        merge_workspace_diagnostics(
331            self.workspace_diagnostics.clone(),
332            fallow_config::registry_diagnostics_to_fold(&self.config.root),
333        )
334    }
335
336    pub(crate) fn styling_analysis_artifacts(
337        &self,
338    ) -> Arc<crate::health::StylingAnalysisArtifacts> {
339        if let Ok(cache) = self.styling_cache.lock()
340            && let Some(artifacts) = cache.as_ref()
341        {
342            return Arc::clone(artifacts);
343        }
344
345        let modules = self.shared_parsed_modules(false);
346        let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
347            self.files(),
348            &modules,
349            self.config(),
350        ));
351        if let Ok(mut cache) = self.styling_cache.lock() {
352            *cache = Some(Arc::clone(&artifacts));
353        }
354        artifacts
355    }
356
357    /// Consume the session and return the resolved config plus discovery data.
358    #[must_use]
359    pub fn into_parts(self) -> AnalysisSessionParts {
360        let workspace_diagnostics = self.current_workspace_diagnostics();
361        AnalysisSessionParts {
362            config: self.config,
363            config_path: self.config_path,
364            files: self.discovery.into_files(),
365            workspaces: self.workspaces,
366            workspace_diagnostics,
367        }
368    }
369
370    /// Consume the session, load the parser cache, and parse discovered files.
371    #[must_use]
372    pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
373        let AnalysisSessionParts {
374            config,
375            config_path,
376            files,
377            workspaces,
378            workspace_diagnostics,
379        } = self.into_parts();
380        let ParsedModules {
381            modules,
382            metrics,
383            source_diagnostics,
384        } = parse_files_with_config(&config, &files, need_complexity);
385        ParsedAnalysisSessionParts {
386            config,
387            config_path,
388            files,
389            modules,
390            workspaces,
391            workspace_diagnostics: merge_workspace_diagnostics(
392                workspace_diagnostics,
393                source_diagnostics,
394            ),
395            parse_ms: metrics.parse_ms,
396            cache_update_ms: metrics.cache_ms,
397            cache_hits: metrics.cache_hits,
398            cache_misses: metrics.cache_misses,
399            parse_cpu_ms: metrics.parse_cpu_ms,
400        }
401    }
402
403    /// Parse discovered files without consuming the session.
404    #[must_use]
405    pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
406        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
407        self.parsed_parts_from_modules(modules.to_vec(), metrics)
408    }
409
410    /// Parse discovered files while retaining shared immutable module storage.
411    #[must_use]
412    pub(crate) fn shared_parsed_parts(
413        &self,
414        need_complexity: bool,
415    ) -> SharedParsedAnalysisSessionParts {
416        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
417        SharedParsedAnalysisSessionParts {
418            config: self.config.clone(),
419            files: self.discovery.files().to_vec(),
420            modules,
421            workspaces: self.workspaces.clone(),
422            workspace_diagnostics: self.current_workspace_diagnostics(),
423            parse_ms: metrics.parse_ms,
424            parse_cpu_ms: metrics.parse_cpu_ms,
425        }
426    }
427
428    /// Return immutable parsed modules backed by the reusable session cache.
429    ///
430    /// Workspace-owned consumers use this additive path when they only need
431    /// parsed modules and can borrow discovery and config directly from the
432    /// session. Stable owned callers can continue using [`Self::parsed_parts`].
433    #[doc(hidden)]
434    #[must_use]
435    pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
436        self.parse_modules(need_complexity).modules
437    }
438
439    /// Parse discovered files without consuming the session or retaining parser
440    /// output in the session cache.
441    #[must_use]
442    pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
443        let ParsedModules {
444            modules,
445            metrics,
446            source_diagnostics: _,
447        } = parse_files_with_config(&self.config, self.files(), need_complexity);
448        self.parsed_parts_from_modules(modules, metrics)
449    }
450
451    fn parsed_parts_from_modules(
452        &self,
453        modules: Vec<ModuleInfo>,
454        metrics: core_backend::ParseMetrics,
455    ) -> ParsedAnalysisSessionParts {
456        ParsedAnalysisSessionParts {
457            config: self.config.clone(),
458            config_path: self.config_path.clone(),
459            files: self.discovery.files().to_vec(),
460            modules,
461            workspaces: self.workspaces.clone(),
462            workspace_diagnostics: self.current_workspace_diagnostics(),
463            parse_ms: metrics.parse_ms,
464            cache_update_ms: metrics.cache_ms,
465            cache_hits: metrics.cache_hits,
466            cache_misses: metrics.cache_misses,
467            parse_cpu_ms: metrics.parse_cpu_ms,
468        }
469    }
470
471    /// Run dead-code analysis for this session.
472    ///
473    /// # Errors
474    ///
475    /// Returns an error if parsing or analysis fails.
476    pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
477        self.analyze_dead_code_with_artifacts(false, false)
478            .map(|output| DeadCodeAnalysis {
479                results: output.results,
480            })
481    }
482
483    /// Run dead-code analysis with retained complexity artifacts.
484    ///
485    /// # Errors
486    ///
487    /// Returns an error if parsing or analysis fails.
488    pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
489        self.analyze_dead_code_with_artifacts(true, false)
490            .map(|output| DeadCodeAnalysisOutput {
491                results: output.results,
492                modules: output.modules,
493                files: output.files,
494            })
495    }
496
497    /// Run dead-code analysis with retained modules, discovered files and graph.
498    ///
499    /// # Errors
500    ///
501    /// Returns an error if parsing or analysis fails.
502    pub fn analyze_dead_code_with_artifacts(
503        &self,
504        need_complexity: bool,
505        retain_graph: bool,
506    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
507        self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
508            .map(SharedDeadCodeAnalysisArtifacts::into_owned)
509    }
510
511    /// Run dead-code analysis with shared immutable parser artifacts.
512    ///
513    /// Workspace-owned consumers use this additive path to retain warm parser
514    /// modules without deep-cloning the session cache. External callers can
515    /// continue using [`Self::analyze_dead_code_with_artifacts`].
516    ///
517    /// # Errors
518    ///
519    /// Returns an error if parsing or analysis fails.
520    #[doc(hidden)]
521    pub fn analyze_dead_code_with_shared_artifacts(
522        &self,
523        need_complexity: bool,
524        retain_graph: bool,
525    ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
526        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
527    }
528
529    /// Run dead-code analysis while retaining discovered files for downstream
530    /// command stages that reuse discovery but do not need parser modules.
531    ///
532    /// # Errors
533    ///
534    /// Returns an error if parsing or analysis fails.
535    pub fn analyze_dead_code_retaining_files(
536        &self,
537        need_complexity: bool,
538        retain_graph: bool,
539    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
540        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
541            .map(SharedDeadCodeAnalysisArtifacts::into_owned)
542    }
543
544    /// Run dead-code analysis from modules already parsed through this session.
545    ///
546    /// This preserves the session's resolved config and discovered file set for
547    /// follow-up analyses that reuse parser output without redoing discovery.
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if graph construction or analysis fails.
552    pub fn analyze_dead_code_with_parsed_modules(
553        &self,
554        modules: &[ModuleInfo],
555    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
556        self.analyze_dead_code_with_shared_modules(Arc::from(modules))
557    }
558
559    /// Run dead-code analysis from shared immutable parser modules.
560    ///
561    /// # Errors
562    ///
563    /// Returns an error if graph construction or analysis fails.
564    #[doc(hidden)]
565    pub(crate) fn analyze_dead_code_with_shared_modules(
566        &self,
567        modules: Arc<[ModuleInfo]>,
568    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
569        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
570            config: &self.config,
571            discovery: &self.discovery,
572            modules,
573            metrics: reused_parse_metrics(),
574            collect_usages: true,
575            retain_graph: true,
576            retain_modules: false,
577            retain_files: false,
578        })
579        .map(SharedDeadCodeAnalysisArtifacts::into_owned)
580    }
581
582    fn analyze_dead_code_with_reuse_artifacts(
583        &self,
584        need_complexity: bool,
585        retain_graph: bool,
586        retain_files: bool,
587    ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
588        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
589        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
590            config: &self.config,
591            discovery: &self.discovery,
592            modules,
593            metrics,
594            collect_usages: true,
595            retain_graph,
596            retain_modules: need_complexity,
597            retain_files,
598        })
599    }
600
601    /// Run dead-code analysis and return the session-scoped reuse artifacts.
602    ///
603    /// Callers pass a changed-file set they have already resolved for the
604    /// command. The returned value keeps that set beside parser, graph, and
605    /// source-fingerprint data so downstream runners do not have to rebuild or
606    /// rediscover the same inputs.
607    ///
608    /// # Errors
609    ///
610    /// Returns an error if parsing or analysis fails.
611    pub fn analyze_dead_code_with_session_artifacts(
612        &self,
613        need_complexity: bool,
614        retain_graph: bool,
615        changed_files: Option<FxHashSet<PathBuf>>,
616    ) -> EngineResult<AnalysisSessionArtifacts> {
617        Ok(AnalysisSessionArtifacts {
618            analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
619            changed_files,
620            source_fingerprints: self.source_fingerprints(),
621        })
622    }
623
624    /// Run duplication detection using the session's discovered files.
625    #[must_use]
626    pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
627        duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
628    }
629
630    /// Run duplication detection using custom duplicate options.
631    #[must_use]
632    pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
633        duplicates::find_duplicates(&self.config.root, self.files(), config)
634    }
635
636    /// Run dead-code and duplication analysis for this session.
637    ///
638    /// When `retain_complexity_artifacts` is true, the dead-code result keeps
639    /// parser artifacts needed by editor overlays such as inline complexity.
640    ///
641    /// # Errors
642    ///
643    /// Returns an error if dead-code parsing or analysis fails.
644    pub fn analyze_project_with(
645        &self,
646        duplicates_config: &DuplicatesConfig,
647        retain_complexity_artifacts: bool,
648    ) -> EngineResult<ProjectAnalysisOutput> {
649        self.analyze_project_with_artifacts(
650            duplicates_config,
651            ProjectAnalysisArtifactOptions {
652                retain_complexity_artifacts,
653                ..ProjectAnalysisArtifactOptions::default()
654            },
655        )
656        .map(ProjectAnalysisArtifacts::into_output)
657    }
658
659    /// Run dead-code and duplication analysis with retained session reuse data.
660    ///
661    /// This is the engine-owned project artifact boundary for callers that need
662    /// to hand one analysis result across audit, decision, editor, or follow-up
663    /// analysis surfaces without rediscovering session metadata.
664    ///
665    /// # Errors
666    ///
667    /// Returns an error if dead-code parsing or analysis fails.
668    pub fn analyze_project_with_artifacts(
669        &self,
670        duplicates_config: &DuplicatesConfig,
671        options: ProjectAnalysisArtifactOptions,
672    ) -> EngineResult<ProjectAnalysisArtifacts> {
673        let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
674        let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
675            let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
676            self.find_duplicates_touching_files_with_defaults(
677                duplicates_config,
678                &changed_files,
679                cache_dir,
680            )
681            .report
682        } else {
683            self.find_duplicates_with_defaults(duplicates_config, cache_dir)
684                .report
685        };
686        let source_fingerprints = options
687            .collect_source_fingerprints
688            .then(|| self.source_fingerprints());
689        Ok(ProjectAnalysisArtifacts {
690            dead_code: self.analyze_dead_code_with_artifacts(
691                options.retain_complexity_artifacts,
692                options.retain_graph,
693            )?,
694            duplication,
695            changed_files: options.changed_files,
696            source_fingerprints,
697        })
698    }
699
700    /// Run duplication detection and return report sidecar metadata.
701    #[must_use]
702    pub fn find_duplicates_with_defaults(
703        &self,
704        config: &DuplicatesConfig,
705        cache_dir: Option<&Path>,
706    ) -> DuplicationAnalysis {
707        duplicates::find_duplicates_with_defaults(
708            &self.config.root,
709            self.files(),
710            config,
711            cache_dir,
712        )
713    }
714
715    /// Run focused duplication detection for a changed-file set.
716    #[must_use]
717    pub fn find_duplicates_touching_files_with_defaults(
718        &self,
719        config: &DuplicatesConfig,
720        changed_files: &[PathBuf],
721        cache_dir: Option<&Path>,
722    ) -> DuplicationAnalysis {
723        duplicates::find_duplicates_touching_files_with_defaults(
724            &self.config.root,
725            self.files(),
726            config,
727            changed_files,
728            cache_dir,
729        )
730    }
731
732    fn parse_modules(&self, need_complexity: bool) -> SharedParsedModules {
733        let fingerprints = source_fingerprints_for_files(self.files());
734        if let Some(fingerprints) = fingerprints.as_ref()
735            && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
736        {
737            return SharedParsedModules {
738                modules,
739                metrics: core_backend::ParseMetrics {
740                    parse_ms: 0.0,
741                    cache_ms: 0.0,
742                    cache_hits: 0,
743                    cache_misses: 0,
744                    parse_cpu_ms: 0.0,
745                },
746            };
747        }
748
749        let ParsedModules {
750            modules,
751            metrics,
752            source_diagnostics: _,
753        } = parse_files_with_config(&self.config, self.files(), need_complexity);
754        let modules: Arc<[ModuleInfo]> = modules.into();
755        if let Some(fingerprints) = fingerprints
756            && let Ok(mut cache) = self.parsed_cache.lock()
757        {
758            *cache = Some(ParsedModuleCache {
759                need_complexity,
760                fingerprints,
761                modules: Arc::clone(&modules),
762            });
763        }
764        SharedParsedModules { modules, metrics }
765    }
766
767    fn cached_modules(
768        &self,
769        need_complexity: bool,
770        fingerprints: &[SourceFingerprint],
771    ) -> Option<Arc<[ModuleInfo]>> {
772        let Ok(cache) = self.parsed_cache.lock() else {
773            return None;
774        };
775        let cache = cache.as_ref()?;
776        let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
777        if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
778            return Some(Arc::clone(&cache.modules));
779        }
780        None
781    }
782}
783
784struct ParsedModules {
785    modules: Vec<ModuleInfo>,
786    metrics: core_backend::ParseMetrics,
787    source_diagnostics: Vec<WorkspaceDiagnostic>,
788}
789
790struct SharedParsedModules {
791    modules: Arc<[ModuleInfo]>,
792    metrics: core_backend::ParseMetrics,
793}
794
795fn parse_files_with_config(
796    config: &ResolvedConfig,
797    files: &[DiscoveredFile],
798    need_complexity: bool,
799) -> ParsedModules {
800    let parse_start = Instant::now();
801    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
802    let mut cache = if config.no_cache {
803        None
804    } else {
805        fallow_extract::cache::CacheStore::load(
806            &config.cache_dir,
807            config.cache_config_hash,
808            cache_max_size_bytes,
809        )
810    };
811    let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
812    let source_diagnostics =
813        fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
814    let mut modules = parse_result.modules;
815    for module in &mut modules {
816        module.prepare_analysis_facts();
817    }
818    let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
819    let cache_ms = update_parse_cache_if_enabled(config, &mut cache, &modules, files);
820    let metrics = core_backend::ParseMetrics {
821        parse_ms,
822        cache_ms,
823        cache_hits: parse_result.cache_hits,
824        cache_misses: parse_result.cache_misses,
825        parse_cpu_ms: parse_result.parse_cpu_ms,
826    };
827    ParsedModules {
828        modules,
829        metrics,
830        source_diagnostics,
831    }
832}
833
834fn reused_parse_metrics() -> core_backend::ParseMetrics {
835    core_backend::ParseMetrics {
836        parse_ms: 0.0,
837        cache_ms: 0.0,
838        cache_hits: 0,
839        cache_misses: 0,
840        parse_cpu_ms: 0.0,
841    }
842}
843
844fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
845    files
846        .iter()
847        .map(|file| {
848            std::fs::metadata(&file.path)
849                .ok()
850                .map(|metadata| SourceFingerprint::from_metadata(&metadata))
851                .filter(|fingerprint| fingerprint.has_known_mtime())
852        })
853        .collect()
854}
855
856fn update_parse_cache_if_enabled(
857    config: &ResolvedConfig,
858    cache: &mut Option<fallow_extract::cache::CacheStore>,
859    modules: &[ModuleInfo],
860    files: &[DiscoveredFile],
861) -> f64 {
862    let start = Instant::now();
863    if config.no_cache {
864        return start.elapsed().as_secs_f64() * 1000.0;
865    }
866
867    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
868    let store = cache.get_or_insert_with(fallow_extract::cache::CacheStore::new);
869    if update_parse_cache(store, modules, files)
870        && let Err(error) = store.save(
871            &config.cache_dir,
872            config.cache_config_hash,
873            cache_max_size_bytes,
874        )
875    {
876        tracing::warn!("Failed to save cache: {error}");
877    }
878    start.elapsed().as_secs_f64() * 1000.0
879}
880
881fn update_parse_cache(
882    store: &mut fallow_extract::cache::CacheStore,
883    modules: &[ModuleInfo],
884    files: &[DiscoveredFile],
885) -> bool {
886    let mut dirty = false;
887    for module in modules {
888        if let Some(file) = files.get(module.file_id.0 as usize) {
889            let fingerprint = source_fingerprint(&file.path);
890            if let Some(cached) = store.get_by_path_only(&file.path)
891                && cached.content_hash == module.content_hash
892            {
893                if cached.source_fingerprint() != fingerprint {
894                    let preserved_last_access = cached.last_access_secs;
895                    let mut refreshed =
896                        fallow_extract::cache::module_to_cached(module, fingerprint);
897                    refreshed.last_access_secs = preserved_last_access;
898                    store.insert(&file.path, refreshed);
899                    dirty = true;
900                }
901                continue;
902            }
903            store.insert(
904                &file.path,
905                fallow_extract::cache::module_to_cached(module, fingerprint),
906            );
907            dirty = true;
908        }
909    }
910    store.retain_paths(files) || dirty
911}
912
913fn source_fingerprint(path: &Path) -> SourceFingerprint {
914    std::fs::metadata(path).map_or_else(
915        |_| SourceFingerprint::new(0, 0),
916        |metadata| SourceFingerprint::from_metadata(&metadata),
917    )
918}
919
920struct EngineDeadCodePipelineInput<'a> {
921    config: &'a ResolvedConfig,
922    discovery: &'a crate::discover::AnalysisDiscovery,
923    modules: Arc<[ModuleInfo]>,
924    metrics: core_backend::ParseMetrics,
925    collect_usages: bool,
926    retain_graph: bool,
927    retain_modules: bool,
928    retain_files: bool,
929}
930
931fn run_engine_owned_dead_code_pipeline(
932    input: EngineDeadCodePipelineInput<'_>,
933) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
934    let EngineDeadCodePipelineInput {
935        config,
936        discovery,
937        modules,
938        metrics,
939        collect_usages,
940        retain_graph,
941        retain_modules,
942        retain_files,
943    } = input;
944    let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
945    let prelude_timings = prelude.timings();
946    let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
947    let (resolved, graph) = resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
948
949    let mut detector = core_backend::run_dead_code_detectors(
950        &prelude,
951        &graph.graph,
952        &resolved.project.modules,
953        &modules,
954        collect_usages,
955        &entry_points,
956    );
957    crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
958    let profile =
959        core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
960            retain_timings: retain_graph,
961            prelude: &prelude,
962            prelude_timings,
963            parse_metrics: metrics,
964            module_count: modules.len(),
965            entry_points: &entry_points,
966            resolved: &resolved,
967            graph: &graph,
968            detector: &detector,
969            file_count: discovery.files().len(),
970            workspace_count: discovery.workspaces().len(),
971        });
972    let script_used_packages = prelude.script_used_packages();
973    prelude.finish();
974    let file_hashes = collect_file_hashes(&modules, discovery.files());
975
976    Ok(SharedDeadCodeAnalysisArtifacts {
977        results: detector.results,
978        timings: profile.timings,
979        graph: retain_graph.then_some(graph.graph),
980        modules: retain_modules.then_some(modules),
981        files: retain_files.then(|| discovery.files().to_vec()),
982        script_used_packages,
983        file_hashes,
984    })
985}
986
987fn resolve_or_build_dead_code_graph(
988    prelude: &core_backend::DeadCodeBackendPrelude,
989    entry_points: &core_backend::DeadCodeEntryPoints,
990    modules: &[ModuleInfo],
991) -> (
992    core_backend::DeadCodeResolvedModules,
993    core_backend::DeadCodeGraphRun,
994) {
995    if let Some((resolved, graph)) =
996        core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules)
997    {
998        return (resolved, graph);
999    }
1000
1001    let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1002    let graph =
1003        core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1004    (resolved, graph)
1005}
1006
1007fn collect_file_hashes(
1008    modules: &[ModuleInfo],
1009    files: &[DiscoveredFile],
1010) -> FxHashMap<PathBuf, u64> {
1011    modules
1012        .iter()
1013        .filter_map(|module| {
1014            files
1015                .get(module.file_id.0 as usize)
1016                .map(|file| (file.path.clone(), module.content_hash))
1017        })
1018        .collect()
1019}
1020
1021pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1022    config: &ResolvedConfig,
1023    modules: &[ModuleInfo],
1024) -> EngineResult<DeadCodeAnalysisArtifacts> {
1025    let (workspaces, _diagnostics, workspaces_ms) =
1026        crate::project_config::collect_workspace_metadata(config)?;
1027    let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1028        config,
1029        &workspaces,
1030        workspaces_ms,
1031    );
1032    run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1033        config,
1034        discovery: &discovery,
1035        modules: Arc::from(modules),
1036        metrics: reused_parse_metrics(),
1037        collect_usages: true,
1038        retain_graph: true,
1039        retain_modules: false,
1040        retain_files: false,
1041    })
1042    .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048
1049    fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1050        let project = tempfile::tempdir().expect("project");
1051        let root = project.path();
1052        std::fs::create_dir(root.join("src")).expect("create source directory");
1053        std::fs::write(root.join("src/index.ts"), source).expect("write source");
1054        let session = AnalysisSession::load_default(root);
1055        (project, session)
1056    }
1057
1058    #[test]
1059    fn session_retains_workspace_metadata_from_config_load() {
1060        let project = tempfile::tempdir().expect("project");
1061        let root = project.path();
1062        std::fs::write(
1063            root.join("package.json"),
1064            r#"{"name":"root","workspaces":["packages/*"]}"#,
1065        )
1066        .expect("write root package");
1067        std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1068        std::fs::write(
1069            root.join("packages/a/package.json"),
1070            r#"{"name":"pkg-a","type":"module"}"#,
1071        )
1072        .expect("write workspace package");
1073
1074        let session = AnalysisSession::load(root, None).expect("session loads");
1075
1076        assert!(
1077            session
1078                .workspaces()
1079                .iter()
1080                .any(|workspace| workspace.name == "pkg-a"),
1081            "session must retain workspace metadata discovered during config load"
1082        );
1083    }
1084
1085    #[test]
1086    fn finding_ignore_filters_results_without_removing_graph_inputs() {
1087        let project = tempfile::tempdir().expect("project");
1088        let root = project.path();
1089        std::fs::create_dir(root.join("src")).expect("create source directory");
1090        std::fs::write(
1091            root.join("package.json"),
1092            r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
1093        )
1094        .expect("write package manifest");
1095        std::fs::write(
1096            root.join("vitest.config.ts"),
1097            "import './src/feature';\nexport default {};\n",
1098        )
1099        .expect("write vitest config");
1100        std::fs::write(
1101            root.join("src/feature.ts"),
1102            "export const feature = true;\n",
1103        )
1104        .expect("write reachable source");
1105        std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
1106            .expect("write hidden source");
1107
1108        let unfiltered = AnalysisSession::load(root, None)
1109            .expect("unfiltered session loads")
1110            .analyze_dead_code()
1111            .expect("unfiltered analysis succeeds");
1112        assert!(
1113            unfiltered
1114                .results
1115                .unused_files
1116                .iter()
1117                .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
1118        );
1119
1120        std::fs::write(
1121            root.join(".fallowrc.json"),
1122            r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1123        )
1124        .expect("write fallow config");
1125        let session = AnalysisSession::load(root, None).expect("filtered session loads");
1126        let hidden_path = root.join("src/hidden.ts");
1127        assert!(session.files().iter().any(|file| file.path == hidden_path));
1128
1129        let filtered = session
1130            .analyze_dead_code_with_artifacts(false, true)
1131            .expect("filtered analysis succeeds");
1132        assert!(
1133            filtered
1134                .results
1135                .unused_files
1136                .iter()
1137                .all(|finding| finding.file.path != hidden_path)
1138        );
1139        assert!(
1140            filtered
1141                .graph
1142                .as_ref()
1143                .is_some_and(|graph| graph.module_count() == session.files().len())
1144        );
1145    }
1146
1147    #[test]
1148    fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
1149        use fallow_types::output_dead_code::UnusedFileFinding;
1150        use fallow_types::results::UnusedFile;
1151
1152        let project = tempfile::tempdir().expect("project");
1153        let config = serde_json::from_str::<fallow_config::FallowConfig>(
1154            r#"{"ignoreFindings":["**/*.ts"]}"#,
1155        )
1156        .expect("config parses")
1157        .resolve(
1158            project.path().to_path_buf(),
1159            fallow_config::OutputFormat::Human,
1160            1,
1161            true,
1162            true,
1163            None,
1164        );
1165        let outside = project
1166            .path()
1167            .parent()
1168            .expect("project has parent")
1169            .join("outside.ts");
1170        let mut results = AnalysisResults {
1171            unused_files: vec![
1172                UnusedFileFinding::with_actions(UnusedFile {
1173                    path: PathBuf::from(r"src\hidden.ts"),
1174                }),
1175                UnusedFileFinding::with_actions(UnusedFile {
1176                    path: outside.clone(),
1177                }),
1178            ],
1179            ..AnalysisResults::default()
1180        };
1181
1182        crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
1183
1184        assert_eq!(results.unused_files.len(), 1);
1185        assert_eq!(results.unused_files[0].file.path, outside);
1186    }
1187
1188    #[test]
1189    fn warm_parse_cache_reuses_module_storage() {
1190        let (_project, session) = session_with_source("export function value() { return 1; }\n");
1191        let first = session.parse_modules(true);
1192        let second = session.parse_modules(false);
1193
1194        assert!(
1195            Arc::ptr_eq(&first.modules, &second.modules),
1196            "warm session queries must share parsed module storage"
1197        );
1198    }
1199
1200    #[test]
1201    fn warm_styling_cache_reuses_artifact_allocation() {
1202        let project = tempfile::tempdir().expect("project");
1203        let root = project.path();
1204        std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
1205            .expect("write stylesheet");
1206        let session = AnalysisSession::load_default(root);
1207
1208        let first = session.styling_analysis_artifacts();
1209        let second = session.styling_analysis_artifacts();
1210
1211        assert!(
1212            Arc::ptr_eq(&first, &second),
1213            "warm styling queries must share the cached artifact allocation"
1214        );
1215    }
1216
1217    #[test]
1218    fn shared_parsed_modules_reuse_public_session_storage() {
1219        let (_project, session) = session_with_source("export const value = 1;\n");
1220        let first = session.shared_parsed_modules(true);
1221        let second = session.shared_parsed_modules(false);
1222
1223        assert!(Arc::ptr_eq(&first, &second));
1224    }
1225
1226    #[test]
1227    fn parsed_parts_keep_owned_module_compatibility() {
1228        let (_project, session) = session_with_source("export const value = 1;\n");
1229        let parts: ParsedAnalysisSessionParts = session.parsed_parts(false);
1230
1231        let _: Vec<ModuleInfo> = parts.modules;
1232    }
1233
1234    #[test]
1235    fn shared_parsed_parts_reuse_public_session_storage() {
1236        let (_project, session) = session_with_source("export const value = 1;\n");
1237        let cached = session.shared_parsed_modules(true);
1238        let parts = session.shared_parsed_parts(false);
1239
1240        assert!(Arc::ptr_eq(&cached, &parts.modules));
1241    }
1242
1243    #[test]
1244    fn warm_complexity_artifacts_reuse_cached_module_storage() {
1245        let (_project, session) = session_with_source("export function value() { return 1; }\n");
1246        let cached = session.parse_modules(true);
1247        let artifacts = session
1248            .analyze_dead_code_with_reuse_artifacts(true, true, false)
1249            .expect("analysis succeeds");
1250        let retained = artifacts.modules.expect("complexity modules retained");
1251
1252        assert!(
1253            Arc::ptr_eq(&cached.modules, &retained),
1254            "warm complexity artifacts must share parsed module storage"
1255        );
1256    }
1257
1258    #[test]
1259    fn shared_and_owned_artifacts_preserve_output_bytes() {
1260        let (_project, session) = session_with_source(
1261            "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
1262        );
1263        let owned = session
1264            .analyze_dead_code_with_artifacts(true, true)
1265            .expect("owned analysis succeeds");
1266        let shared = session
1267            .analyze_dead_code_with_shared_artifacts(true, true)
1268            .expect("shared analysis succeeds");
1269
1270        assert_eq!(
1271            serde_json::to_vec(&owned.results).expect("serialize owned results"),
1272            serde_json::to_vec(&shared.results).expect("serialize shared results")
1273        );
1274        assert_eq!(owned.file_hashes, shared.file_hashes);
1275        assert_eq!(
1276            owned
1277                .modules
1278                .as_deref()
1279                .unwrap_or_default()
1280                .iter()
1281                .map(|module| module.content_hash)
1282                .collect::<Vec<_>>(),
1283            shared
1284                .modules
1285                .as_deref()
1286                .unwrap_or_default()
1287                .iter()
1288                .map(|module| module.content_hash)
1289                .collect::<Vec<_>>()
1290        );
1291    }
1292
1293    #[test]
1294    fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
1295        let project = tempfile::tempdir().expect("project");
1296        let root = project.path();
1297        std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
1298        std::fs::write(
1299            root.join("package.json"),
1300            r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
1301        )
1302        .expect("write package manifest");
1303        std::fs::write(
1304            root.join("app/routes/home.tsx"),
1305            r#"
1306import { useLoaderData } from "react-router";
1307export function loader() { return { opaque: "value" }; }
1308export default function Home() {
1309  const data = useLoaderData<typeof loader>();
1310  const copy = { ...data };
1311  return JSON.stringify(copy);
1312}
1313"#,
1314        )
1315        .expect("write route module");
1316
1317        let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1318        let cold_parse = cold_session.parsed_parts(false);
1319        assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
1320        let cold = cold_session
1321            .analyze_dead_code()
1322            .expect("cold analysis succeeds");
1323
1324        let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1325        let warm_parse = warm_session.parsed_parts(false);
1326        assert!(
1327            warm_parse.cache_hits > 0,
1328            "second session must use disk cache"
1329        );
1330        let warm = warm_session
1331            .analyze_dead_code()
1332            .expect("warm analysis succeeds");
1333
1334        assert!(
1335            cold.results.unused_load_data_keys.is_empty(),
1336            "cold analysis must abstain for an opaque route-loader use"
1337        );
1338        assert_eq!(
1339            serde_json::to_vec(&cold.results).expect("serialize cold results"),
1340            serde_json::to_vec(&warm.results).expect("serialize warm results"),
1341            "warm route-loader analysis must match cold analysis"
1342        );
1343    }
1344
1345    #[test]
1346    fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
1347        let project = tempfile::tempdir().expect("project");
1348        let root = project.path();
1349        std::fs::create_dir(root.join("src")).expect("create source directory");
1350        std::fs::write(
1351            root.join("package.json"),
1352            r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
1353        )
1354        .expect("write package manifest");
1355        std::fs::write(
1356            root.join("src/dependency.ts"),
1357            "export function dependency() { return 'real'; }\n",
1358        )
1359        .expect("write dependency");
1360        std::fs::write(
1361            root.join("src/wrapper.ts"),
1362            "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
1363        )
1364        .expect("write wrapper");
1365        std::fs::write(
1366            root.join("src/index.ts"),
1367            "export { wrapper } from './wrapper';\n",
1368        )
1369        .expect("write entry point");
1370        std::fs::write(
1371            root.join("src/wrapper.test.ts"),
1372            r#"
1373import { vi } from "vitest";
1374vi.mock("./dependency", () => ({ dependency: () => "mock" }));
1375import { wrapper } from "./wrapper";
1376wrapper();
1377"#,
1378        )
1379        .expect("write test");
1380
1381        let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1382        let dependency_id = cold_session
1383            .files()
1384            .iter()
1385            .find(|file| file.path == root.join("src/dependency.ts"))
1386            .expect("dependency discovered")
1387            .id;
1388        let cold = cold_session
1389            .analyze_dead_code_with_artifacts(false, true)
1390            .expect("cold analysis succeeds");
1391        let cold_exports = crate::module_graph::module_value_exports(
1392            cold.graph.as_ref().expect("cold graph retained"),
1393        );
1394        assert!(
1395            fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_some(),
1396            "cold analysis must persist the graph cache"
1397        );
1398
1399        let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1400        let warm = warm_session
1401            .analyze_dead_code_with_artifacts(false, true)
1402            .expect("warm analysis succeeds");
1403        let warm_exports = crate::module_graph::module_value_exports(
1404            warm.graph.as_ref().expect("warm graph retained"),
1405        );
1406
1407        let dependency = cold_exports
1408            .iter()
1409            .find(|export| export.file_id == dependency_id && export.name == "dependency")
1410            .expect("dependency export retained");
1411        assert!(!dependency.test_referenced);
1412        assert_eq!(warm_exports, cold_exports);
1413    }
1414
1415    #[test]
1416    fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
1417        let project = tempfile::tempdir().expect("project");
1418        let root = project.path();
1419        std::fs::create_dir(root.join("src")).expect("create source directory");
1420        std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
1421            .expect("write package manifest");
1422        for name in ["a.ts", "b.ts", "c.ts"] {
1423            std::fs::write(
1424                root.join("src").join(name),
1425                format!("export const {} = 1;\n", name.replace('.', "_")),
1426            )
1427            .expect("write source");
1428        }
1429        let session = AnalysisSession::load(root, None).expect("session loads");
1430        let removed_path = root.join("src/b.ts");
1431        let removed_id = session
1432            .files()
1433            .iter()
1434            .find(|file| file.path == removed_path)
1435            .expect("removed source discovered")
1436            .id;
1437        std::fs::remove_file(&removed_path).expect("remove source after discovery");
1438
1439        let parts = session.parsed_parts(false);
1440
1441        assert!(
1442            parts
1443                .modules
1444                .iter()
1445                .all(|module| module.file_id != removed_id),
1446            "unreadable file must not receive a placeholder module"
1447        );
1448        let diagnostic = parts
1449            .workspace_diagnostics
1450            .iter()
1451            .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
1452            .expect("parsed session parts carry source read failure");
1453        assert_eq!(diagnostic.path, removed_path);
1454        assert!(
1455            session
1456                .current_workspace_diagnostics()
1457                .iter()
1458                .any(|diagnostic| {
1459                    diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
1460                }),
1461            "session output carries parse-time source diagnostics"
1462        );
1463    }
1464
1465    const MALFORMED_PNPM_WORKSPACE_YAML: &str =
1466        "catalog:\n  react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
1467    const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n  react: ^18.2.0\n";
1468
1469    fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
1470        diagnostics
1471            .iter()
1472            .any(|diagnostic| diagnostic.kind.id() == id)
1473    }
1474
1475    fn write_single_source_project(root: &Path, manifest: &str) {
1476        std::fs::create_dir(root.join("src")).expect("create source directory");
1477        std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
1478        std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
1479            .expect("write source");
1480    }
1481
1482    /// Issue #2366: engine sessions (the MCP and LSP path) never re-stash the
1483    /// registry, so a session created after an earlier analysis in the same
1484    /// process must not keep that analysis's analysis-stage diagnostic once
1485    /// the cause is fixed: the analyze pass refreshes the entry and the
1486    /// session snapshot must not pin it.
1487    #[test]
1488    fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
1489        let project = tempfile::tempdir().expect("project");
1490        let root = project.path();
1491        write_single_source_project(
1492            root,
1493            r#"{"name":"issue-2366-engine-session","private":true}"#,
1494        );
1495        std::fs::write(
1496            root.join("pnpm-workspace.yaml"),
1497            MALFORMED_PNPM_WORKSPACE_YAML,
1498        )
1499        .expect("write malformed workspace yaml");
1500
1501        let broken = AnalysisSession::load(root, None).expect("session loads");
1502        broken
1503            .analyze_dead_code()
1504            .expect("analysis on the malformed yaml succeeds");
1505        assert!(
1506            has_diagnostic_kind(
1507                &broken.current_workspace_diagnostics(),
1508                "malformed-pnpm-workspace-yaml"
1509            ),
1510            "the first session surfaces the malformed yaml: {:?}",
1511            broken.current_workspace_diagnostics()
1512        );
1513
1514        std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
1515            .expect("fix workspace yaml");
1516
1517        let fixed = AnalysisSession::load(root, None).expect("session loads");
1518        fixed
1519            .analyze_dead_code()
1520            .expect("analysis on the fixed yaml succeeds");
1521        let current = fixed.current_workspace_diagnostics();
1522        assert!(
1523            !has_diagnostic_kind(&current, "malformed-pnpm-workspace-yaml"),
1524            "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
1525        );
1526    }
1527
1528    /// Watch-mode rerun shape (issue #2366): the CLI reloads config, which
1529    /// re-stashes the workspace-discovery set, and builds a fresh session from
1530    /// the resolved config before re-analyzing. Once a text `bun.lock` exists
1531    /// the rerun must drop the bun.lockb skip diagnostic. Regression pin: the
1532    /// old stash wiped analysis-stage entries instead of preserving them, so
1533    /// this passes before and after the fix.
1534    #[test]
1535    fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
1536        let project = tempfile::tempdir().expect("project");
1537        let root = project.path();
1538        write_single_source_project(
1539            root,
1540            r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
1541        );
1542        std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
1543            .expect("write bun.lockb placeholder");
1544        let config = fallow_config::FallowConfig::default().resolve(
1545            root.to_path_buf(),
1546            fallow_config::OutputFormat::Json,
1547            1,
1548            true,
1549            true,
1550            None,
1551        );
1552        let reload_config = || {
1553            let (_, diagnostics) =
1554                fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
1555                    .expect("workspace discovery succeeds");
1556            fallow_config::stash_workspace_diagnostics(root, diagnostics);
1557        };
1558
1559        reload_config();
1560        let first =
1561            AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
1562        first
1563            .analyze_dead_code()
1564            .expect("analysis with bun.lockb only succeeds");
1565        assert!(
1566            has_diagnostic_kind(
1567                &first.current_workspace_diagnostics(),
1568                "bun-lockb-override-resolution-skipped"
1569            ),
1570            "the first run surfaces the bun.lockb skip: {:?}",
1571            first.current_workspace_diagnostics()
1572        );
1573
1574        std::fs::write(
1575            root.join("bun.lock"),
1576            r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
1577        )
1578        .expect("write text bun.lock");
1579
1580        reload_config();
1581        let rerun =
1582            AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
1583        rerun
1584            .analyze_dead_code()
1585            .expect("analysis with the text bun.lock succeeds");
1586        let current = rerun.current_workspace_diagnostics();
1587        assert!(
1588            !has_diagnostic_kind(&current, "bun-lockb-override-resolution-skipped"),
1589            "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
1590        );
1591    }
1592
1593    /// Issue #2366: `current_workspace_diagnostics` reads the registry live so
1594    /// the parse-stage and analyze-stage entries that land after the session
1595    /// was created still reach the envelope, but it must not import another
1596    /// walk's skips along with them.
1597    ///
1598    /// Combined mode runs the dead-code and duplication walks on the same root
1599    /// under `rayon::join` whenever a per-analysis `production` split stops
1600    /// them from sharing a file list, and each walk replaces the registry's
1601    /// source-discovery set. A session that read that set back would answer
1602    /// "whichever walk wrote last", which decides where the other walk's skip
1603    /// lands in the combined root's union and made the array come out in a
1604    /// different ORDER between runs of the same command.
1605    #[test]
1606    fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
1607        let project = tempfile::tempdir().expect("project");
1608        let root = project.path();
1609        write_single_source_project(
1610            root,
1611            r#"{"name":"issue-2366-parallel-walks","private":true}"#,
1612        );
1613        std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
1614            .expect("write oversized source");
1615        let mut config = fallow_config::FallowConfig::default().resolve(
1616            root.to_path_buf(),
1617            fallow_config::OutputFormat::Json,
1618            1,
1619            true,
1620            true,
1621            None,
1622        );
1623        config.max_file_size_bytes = Some(1024);
1624
1625        let session = AnalysisSession::from_resolved_config(config).expect("session loads");
1626
1627        // The state a concurrent walk leaves behind: its own skip in this
1628        // root's registry entry. It writes that through the registry's
1629        // replace-in-one-operation call, which an architecture guard reserves
1630        // for the walk itself, so the append is the stand-in here.
1631        fallow_config::append_workspace_diagnostics(
1632            root,
1633            vec![WorkspaceDiagnostic::new(
1634                root,
1635                root.join("src/other-walk-only.ts"),
1636                fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
1637                    size_bytes: 4096,
1638                },
1639            )],
1640        );
1641
1642        let current = session.current_workspace_diagnostics();
1643        let skipped: Vec<&Path> = current
1644            .iter()
1645            .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
1646            .map(|diagnostic| diagnostic.path.as_path())
1647            .collect();
1648        assert_eq!(
1649            skipped.len(),
1650            1,
1651            "the session reports its own walk's skips only: {skipped:?}"
1652        );
1653        assert!(
1654            skipped[0].ends_with("src/huge.ts"),
1655            "the surviving skip is this walk's own: {skipped:?}"
1656        );
1657    }
1658
1659    /// Issue #2366: a config reload that happens AFTER the analyze pass, with
1660    /// no further pass to re-record, must not wipe the analysis-stage entry
1661    /// from the process registry. This is the long-lived-server shape: an MCP
1662    /// or LSP process analyzes once, a later request reloads config for a
1663    /// different analysis family, and a session built after that reload still
1664    /// reads the registry live. Pins the analysis-stage preserve in
1665    /// `stash_workspace_diagnostics`; without it this session reports nothing.
1666    #[test]
1667    fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
1668        let project = tempfile::tempdir().expect("project");
1669        let root = project.path();
1670        write_single_source_project(
1671            root,
1672            r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
1673        );
1674        std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
1675            .expect("write bun.lockb placeholder");
1676        let config = fallow_config::FallowConfig::default().resolve(
1677            root.to_path_buf(),
1678            fallow_config::OutputFormat::Json,
1679            1,
1680            true,
1681            true,
1682            None,
1683        );
1684        let reload_config = || {
1685            let (_, diagnostics) =
1686                fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
1687                    .expect("workspace discovery succeeds");
1688            fallow_config::stash_workspace_diagnostics(root, diagnostics);
1689        };
1690
1691        reload_config();
1692        let analyzing =
1693            AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
1694        analyzing
1695            .analyze_dead_code()
1696            .expect("analysis with bun.lockb only succeeds");
1697
1698        // A later request reloads config for another analysis family and never
1699        // runs a second dead-code pass.
1700        reload_config();
1701
1702        let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
1703        let current = later.current_workspace_diagnostics();
1704        assert!(
1705            has_diagnostic_kind(&current, "bun-lockb-override-resolution-skipped"),
1706            "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
1707             {current:?}"
1708        );
1709    }
1710}