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