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