Skip to main content

fallow_engine/
session.rs

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