Skip to main content

fallow_graph/cache/
mod.rs

1//! Persisted graph-cache identity contracts and on-disk store.
2//!
3//! The manifest types here define the invalidation surface a persisted graph
4//! cache must satisfy before a cached graph can be trusted. Exact manifest hits
5//! can reuse a previously-built `ModuleGraph`; stable-key resolver hits can
6//! reuse resolver output and rebuild the graph with current `FileId`s.
7
8use std::sync::Arc;
9
10use std::path::{Path, PathBuf};
11
12use fallow_types::discover::{DiscoveredFile, FileId, StableFileKey};
13use fallow_types::extract::{ImportInfo, ReExportInfo};
14use fallow_types::source_fingerprint::SourceFingerprint;
15use oxc_span::Span;
16
17use crate::resolve::{
18    ResolveResult, ResolvedImport, ResolvedModule, ResolvedProject, ResolvedReExport,
19    ResolvedReplacedModuleTarget,
20};
21
22mod store;
23
24pub use store::GraphCacheStore;
25
26/// Persisted graph cache schema version.
27///
28/// Bump this whenever the serialized shape of the persisted graph (any of the
29/// graph types that derive serde for the cache, the manifest types, or the
30/// store envelope) changes, so a stale `graph-cache.bin` written by an older
31/// binary is rejected rather than deserialized into the wrong shape.
32///
33/// Bump it for import-resolution semantics changes too, not only for wire-shape
34/// changes. The manifest compares this constant, the cache mode, and per-file
35/// fingerprints, and carries no binary version, so a cache written before a
36/// classification change replays the old classification verbatim on an
37/// unmodified tree and silently hides the new behaviour. The same applies to
38/// plugin config extraction, which seeds entry points and path aliases.
39///
40/// Bumped to 17 for issue #2031: cached resolver output retains canonical
41/// test-root replacements and ESM/CommonJS mechanisms, while profiled graphs
42/// retain target-sparse reachability masks plus exact compact reference routes.
43/// Ordinary graphs omit unused provenance. Versions 7 through 16 were used by
44/// published development commits for this change, so the final version remains
45/// 17 rather than reusing a potentially stale intermediate cache version.
46///
47/// Bumped to 18 for issue #2083: reference provenance moved out of
48/// `SymbolReference` into the per-export `reference_paths` side table, changing
49/// the persisted layout of every export's reference list.
50///
51/// Bumped to 19 for issue #2084 (PR #2096): profiled test-reachability masking
52/// now falls back to the legacy fail-open classification when a graph would
53/// need more than the mask-profile cap, so caches written by the unbounded
54/// profiling code must not replay their old profiled results on large
55/// monorepos where the cap now engages.
56///
57/// Bumped to 20: cached re-export edges now carry the enclosing statement
58/// span and the source string-literal span used to anchor unresolved-import
59/// findings on the specifier. Warm 19 caches lack both spans.
60///
61/// Bumped to 21: persisted module graphs now carry the canonical effective
62/// export index used for named and star re-export resolution. Warm 20 caches
63/// lack that index and would replay the previous propagation semantics.
64///
65/// Bumped to 22: effective export names are interned once per graph and type
66/// and value resolutions share one compact key. Warm 21 caches contain the
67/// allocation-heavy intermediate index layout.
68///
69/// Bumped to 23: effective bindings distinguish direct declarations,
70/// namespace objects, and implicit SFC default exports.
71///
72/// Bumped to 24: namespace-object bindings retain their source module so
73/// consumers can enumerate the namespace through the canonical index.
74///
75/// Bumped to 25: export references retain their semantic Type or Value
76/// namespace so one named re-export surface can route both without duplicate
77/// graph symbols.
78///
79/// Bumped to 26: the effective export index retains typed declaration merge
80/// groups for namespace-aware reference selection.
81///
82/// Bumped to 27: declaration merge groups are stored once and referenced by a
83/// compact per-slot group identifier instead of cloning every group per slot.
84///
85/// Bumped to 28: reference-site deduplication now includes the exact import
86/// span, so one consumer can retain multiple distinct imports of one binding.
87///
88/// Bumped to 29: resolved semantic facts retain directly required structural
89/// type members used to protect explicit interface implementations.
90///
91/// Bumped to 30: declaration-merge references now propagate through named and
92/// star barrel surfaces using the canonical effective binding group.
93///
94/// Bumped to 31 for issue #2213: speculative `__mocks__` sibling candidates
95/// that resolve to package space are no longer emitted, so warm 30 caches
96/// would keep replaying the phantom `@scope/__mocks__` package edges the
97/// resolver no longer produces.
98///
99/// Bumped to 32: the effective export index resolves the type namespace in two
100/// lanes (real declarations and value-derived fallbacks), stores a per-file
101/// name index, and retains opaque bindings for known external named and
102/// namespace re-export surfaces. Warm 31 payloads neither describe the same
103/// structure nor carry those external bindings, so a consumed barrel export
104/// could be falsely reported as unused.
105///
106/// Bumped to 33 for issue #2225: speculative root-level `__mocks__/<specifier>`
107/// candidates from factory-less bare-specifier mocks now resolve to project
108/// files. Warm 32 caches lack those edges, so root manual mocks would stay
109/// reported as unused files.
110/// Bumped to 34: the effective export index only seeds the value-derived type
111/// fallback lane along re-export paths that reach a type-only re-export, and a
112/// type query reads the value lane where that lane is absent. A warm 33 payload
113/// is still read correctly, but a 33 reader would take an absent fallback lane
114/// for an absent type meaning and report consumed exports as unused.
115pub const GRAPH_CACHE_VERSION: u32 = 34;
116
117/// Cached form of a resolved target.
118///
119/// Internal targets are stored by stable file key, not by `FileId`, so resolver
120/// output can be reused across a future FileId assignment shift. The persisted
121/// `ModuleGraph` itself is still `FileId`-keyed; callers may only trust the
122/// cached graph when the manifest's `file_id` assignments match, but they may
123/// remap this resolver payload and rebuild the graph.
124#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
125pub enum CachedResolveResult {
126    /// Resolved to a file within the project.
127    InternalModule(StableFileKey),
128    /// Resolved from CommonJS to a file within the project.
129    CommonJsInternalModule(StableFileKey),
130    /// Resolved to a project file through a framework convention auto-import.
131    SyntheticAutoImport(StableFileKey),
132    /// Resolved to a workspace or self package source file.
133    InternalPackageModule {
134        /// Stable source file reached by the package map.
135        key: StableFileKey,
136        /// Package name that was used in the import specifier.
137        package_name: String,
138    },
139    /// Resolved from CommonJS to workspace or self-package source.
140    CommonJsInternalPackageModule {
141        /// Stable source file reached by the package map.
142        key: StableFileKey,
143        /// Package name used in the require specifier.
144        package_name: String,
145    },
146    /// Resolved to a file outside the project.
147    ExternalFile(PathBuf),
148    /// Bare specifier.
149    NpmPackage(String),
150    /// Bare specifier referenced through CommonJS `require()`.
151    CommonJsNpmPackage(String),
152    /// Could not resolve.
153    Unresolvable(String),
154}
155
156impl CachedResolveResult {
157    fn from_resolve_result(
158        target: &ResolveResult,
159        key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
160    ) -> Option<Self> {
161        Some(match target {
162            ResolveResult::InternalModule(file_id) => {
163                Self::InternalModule(key_by_file_id.get(file_id)?.clone())
164            }
165            ResolveResult::CommonJsInternalModule(file_id) => {
166                Self::CommonJsInternalModule(key_by_file_id.get(file_id)?.clone())
167            }
168            ResolveResult::SyntheticAutoImport(file_id) => {
169                Self::SyntheticAutoImport(key_by_file_id.get(file_id)?.clone())
170            }
171            ResolveResult::InternalPackageModule {
172                file_id,
173                package_name,
174            } => Self::InternalPackageModule {
175                key: key_by_file_id.get(file_id)?.clone(),
176                package_name: package_name.clone(),
177            },
178            ResolveResult::CommonJsInternalPackageModule {
179                file_id,
180                package_name,
181            } => Self::CommonJsInternalPackageModule {
182                key: key_by_file_id.get(file_id)?.clone(),
183                package_name: package_name.clone(),
184            },
185            ResolveResult::ExternalFile(path) => Self::ExternalFile(path.clone()),
186            ResolveResult::NpmPackage(package_name) => Self::NpmPackage(package_name.clone()),
187            ResolveResult::CommonJsNpmPackage(package_name) => {
188                Self::CommonJsNpmPackage(package_name.clone())
189            }
190            ResolveResult::Unresolvable(specifier) => Self::Unresolvable(specifier.clone()),
191        })
192    }
193
194    fn into_resolve_result(
195        self,
196        id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
197    ) -> Option<ResolveResult> {
198        Some(match self {
199            Self::InternalModule(key) => ResolveResult::InternalModule(*id_by_key.get(&key)?),
200            Self::CommonJsInternalModule(key) => {
201                ResolveResult::CommonJsInternalModule(*id_by_key.get(&key)?)
202            }
203            Self::SyntheticAutoImport(key) => {
204                ResolveResult::SyntheticAutoImport(*id_by_key.get(&key)?)
205            }
206            Self::InternalPackageModule { key, package_name } => {
207                ResolveResult::InternalPackageModule {
208                    file_id: *id_by_key.get(&key)?,
209                    package_name,
210                }
211            }
212            Self::CommonJsInternalPackageModule { key, package_name } => {
213                ResolveResult::CommonJsInternalPackageModule {
214                    file_id: *id_by_key.get(&key)?,
215                    package_name,
216                }
217            }
218            Self::ExternalFile(path) => ResolveResult::ExternalFile(path),
219            Self::NpmPackage(package_name) => ResolveResult::NpmPackage(package_name),
220            Self::CommonJsNpmPackage(package_name) => {
221                ResolveResult::CommonJsNpmPackage(package_name)
222            }
223            Self::Unresolvable(specifier) => ResolveResult::Unresolvable(specifier),
224        })
225    }
226}
227
228/// Cached import edge that can be restored without re-running resolution.
229#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
230pub struct CachedResolvedImport {
231    /// Import metadata mirrored from extraction or resolver synthesis.
232    info: CachedImportInfo,
233    /// Resolved target for this import edge.
234    target: CachedResolveResult,
235}
236
237impl CachedResolvedImport {
238    fn from_resolved(
239        import: &ResolvedImport,
240        key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
241    ) -> Option<Self> {
242        Some(Self {
243            info: CachedImportInfo::from(&import.info),
244            target: CachedResolveResult::from_resolve_result(&import.target, key_by_file_id)?,
245        })
246    }
247
248    fn into_resolved(
249        self,
250        id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
251    ) -> Option<ResolvedImport> {
252        Some(ResolvedImport {
253            info: self.info.into(),
254            target: self.target.into_resolve_result(id_by_key)?,
255        })
256    }
257}
258
259/// Cached re-export edge that can be restored without re-running resolution.
260#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
261pub struct CachedResolvedReExport {
262    /// Re-export metadata mirrored from extraction.
263    info: CachedReExportInfo,
264    /// Resolved target for this re-export source.
265    target: CachedResolveResult,
266}
267
268impl CachedResolvedReExport {
269    fn from_resolved(
270        re_export: &ResolvedReExport,
271        key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
272    ) -> Option<Self> {
273        Some(Self {
274            info: CachedReExportInfo::from(&re_export.info),
275            target: CachedResolveResult::from_resolve_result(&re_export.target, key_by_file_id)?,
276        })
277    }
278
279    fn into_resolved(
280        self,
281        id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
282    ) -> Option<ResolvedReExport> {
283        Some(ResolvedReExport {
284            info: self.info.into(),
285            target: self.target.into_resolve_result(id_by_key)?,
286        })
287    }
288}
289
290/// Cache-friendly mirror of [`ImportInfo`].
291#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
292pub struct CachedImportInfo {
293    /// Import source specifier.
294    source: String,
295    /// Imported binding shape.
296    imported_name: fallow_types::extract::ImportedName,
297    /// Local binding name.
298    local_name: String,
299    /// Whether this import is type-only.
300    is_type_only: bool,
301    /// Whether this import originated from a style context.
302    from_style: bool,
303    /// Span of the full import declaration.
304    span: [u32; 2],
305    /// Span of the import source literal.
306    source_span: [u32; 2],
307}
308
309impl From<&ImportInfo> for CachedImportInfo {
310    fn from(info: &ImportInfo) -> Self {
311        Self {
312            source: info.source.clone(),
313            imported_name: info.imported_name.clone(),
314            local_name: info.local_name.clone(),
315            is_type_only: info.is_type_only,
316            from_style: info.from_style,
317            span: span_to_pair(info.span),
318            source_span: span_to_pair(info.source_span),
319        }
320    }
321}
322
323impl From<CachedImportInfo> for ImportInfo {
324    fn from(info: CachedImportInfo) -> Self {
325        Self {
326            source: info.source,
327            imported_name: info.imported_name,
328            local_name: info.local_name,
329            is_type_only: info.is_type_only,
330            from_style: info.from_style,
331            span: pair_to_span(info.span),
332            source_span: pair_to_span(info.source_span),
333        }
334    }
335}
336
337/// Cache-friendly mirror of [`ReExportInfo`].
338#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
339pub struct CachedReExportInfo {
340    /// Re-export source specifier.
341    source: String,
342    /// Imported name from the source module.
343    imported_name: String,
344    /// Exported name from this module.
345    exported_name: String,
346    /// Whether this re-export is type-only.
347    is_type_only: bool,
348    /// Span of the re-export declaration.
349    span: [u32; 2],
350    /// Span of the enclosing re-export statement.
351    statement_span: [u32; 2],
352    /// Span of the source string literal.
353    source_span: [u32; 2],
354}
355
356impl From<&ReExportInfo> for CachedReExportInfo {
357    fn from(info: &ReExportInfo) -> Self {
358        Self {
359            source: info.source.clone(),
360            imported_name: info.imported_name.clone(),
361            exported_name: info.exported_name.clone(),
362            is_type_only: info.is_type_only,
363            span: span_to_pair(info.span),
364            statement_span: span_to_pair(info.statement_span),
365            source_span: span_to_pair(info.source_span),
366        }
367    }
368}
369
370impl From<CachedReExportInfo> for ReExportInfo {
371    fn from(info: CachedReExportInfo) -> Self {
372        Self {
373            source: info.source,
374            imported_name: info.imported_name,
375            exported_name: info.exported_name,
376            is_type_only: info.is_type_only,
377            span: pair_to_span(info.span),
378            statement_span: pair_to_span(info.statement_span),
379            source_span: pair_to_span(info.source_span),
380        }
381    }
382}
383
384/// Cached resolver output for one module.
385#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
386pub struct CachedResolvedModule {
387    /// Stable identity of the source module.
388    key: StableFileKey,
389    /// Static import and require edges after resolution.
390    resolved_imports: Vec<CachedResolvedImport>,
391    /// Literal dynamic import edges after resolution.
392    resolved_dynamic_imports: Vec<CachedResolvedImport>,
393    /// Re-export source edges after resolution.
394    re_exports: Vec<CachedResolvedReExport>,
395    /// Dynamic import pattern targets, aligned with current extracted patterns.
396    resolved_dynamic_pattern_targets: Vec<Vec<StableFileKey>>,
397}
398
399impl CachedResolvedModule {
400    fn from_resolved(
401        module: &ResolvedModule,
402        key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
403    ) -> Option<Self> {
404        Some(Self {
405            key: key_by_file_id.get(&module.file_id)?.clone(),
406            resolved_imports: module
407                .resolved_imports
408                .iter()
409                .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
410                .collect::<Option<Vec<_>>>()?,
411            resolved_dynamic_imports: module
412                .resolved_dynamic_imports
413                .iter()
414                .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
415                .collect::<Option<Vec<_>>>()?,
416            re_exports: module
417                .re_exports
418                .iter()
419                .map(|re_export| CachedResolvedReExport::from_resolved(re_export, key_by_file_id))
420                .collect::<Option<Vec<_>>>()?,
421            resolved_dynamic_pattern_targets: module
422                .resolved_dynamic_patterns
423                .iter()
424                .map(|(_, targets)| {
425                    targets
426                        .iter()
427                        .map(|target| key_by_file_id.get(target).cloned())
428                        .collect::<Option<Vec<_>>>()
429                })
430                .collect::<Option<Vec<_>>>()?,
431        })
432    }
433}
434
435/// Stable-key cache form of one resolved project-internal replacement.
436#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
437struct CachedResolvedReplacedModuleTarget {
438    source_key: StableFileKey,
439    target_key: StableFileKey,
440}
441
442impl CachedResolvedReplacedModuleTarget {
443    fn from_resolved(
444        target: ResolvedReplacedModuleTarget,
445        key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
446    ) -> Option<Self> {
447        Some(Self {
448            source_key: key_by_file_id.get(&target.source_file)?.clone(),
449            target_key: key_by_file_id.get(&target.target_file)?.clone(),
450        })
451    }
452
453    fn into_resolved(
454        self,
455        id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
456    ) -> Option<ResolvedReplacedModuleTarget> {
457        Some(ResolvedReplacedModuleTarget {
458            source_file: *id_by_key.get(&self.source_key)?,
459            target_file: *id_by_key.get(&self.target_key)?,
460        })
461    }
462}
463
464/// Cache-friendly mirror of the complete resolver output.
465#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
466pub struct CachedResolvedProject {
467    modules: Vec<CachedResolvedModule>,
468    replaced_module_targets: Vec<CachedResolvedReplacedModuleTarget>,
469}
470
471/// Convert a resolved project into the compact graph-cache resolver payload.
472#[must_use]
473pub fn cache_resolved_project(
474    root: &Path,
475    files: &[DiscoveredFile],
476    resolved: &ResolvedProject,
477) -> Option<CachedResolvedProject> {
478    let key_by_file_id = stable_key_by_file_id(root, files);
479    let modules = resolved
480        .modules
481        .iter()
482        .map(|module| CachedResolvedModule::from_resolved(module, &key_by_file_id))
483        .collect::<Option<Vec<_>>>()?;
484    let replaced_module_targets = resolved
485        .replaced_module_targets
486        .iter()
487        .copied()
488        .map(|target| CachedResolvedReplacedModuleTarget::from_resolved(target, &key_by_file_id))
489        .collect::<Option<Vec<_>>>()?;
490    Some(CachedResolvedProject {
491        modules,
492        replaced_module_targets,
493    })
494}
495
496/// Restore a resolved project from cached resolver payloads and current parsed modules.
497///
498/// Returns `None` if the payload no longer aligns with the current parse result.
499/// A normal graph-cache manifest hit should keep these aligned; this extra check
500/// keeps corrupt or hand-edited cache files on the safe miss path.
501#[must_use]
502pub fn restore_resolved_project(
503    root: &Path,
504    modules: &[fallow_types::extract::ModuleInfo],
505    files: &[DiscoveredFile],
506    cached: &CachedResolvedProject,
507) -> Option<ResolvedProject> {
508    if modules.len() != cached.modules.len() {
509        return None;
510    }
511
512    let mut indexes = RestoreResolvedModuleIndexes::new(root, modules, files);
513    let resolved_modules = cached
514        .modules
515        .iter()
516        .map(|entry| restore_cached_resolved_module(entry, &mut indexes))
517        .collect::<Option<Vec<_>>>()?;
518    let mut replaced_module_targets = cached
519        .replaced_module_targets
520        .iter()
521        .cloned()
522        .map(|target| target.into_resolved(&indexes.file_ids))
523        .collect::<Option<Vec<_>>>()?;
524    replaced_module_targets
525        .sort_unstable_by_key(|target| (target.source_file.0, target.target_file.0));
526    replaced_module_targets.dedup();
527    Some(ResolvedProject {
528        modules: resolved_modules,
529        replaced_module_targets,
530    })
531}
532
533struct RestoreResolvedModuleIndexes<'a> {
534    file_ids: rustc_hash::FxHashMap<StableFileKey, FileId>,
535    modules: rustc_hash::FxHashMap<StableFileKey, &'a fallow_types::extract::ModuleInfo>,
536    paths: rustc_hash::FxHashMap<StableFileKey, std::path::PathBuf>,
537}
538
539impl<'a> RestoreResolvedModuleIndexes<'a> {
540    fn new(
541        root: &Path,
542        modules: &'a [fallow_types::extract::ModuleInfo],
543        files: &[DiscoveredFile],
544    ) -> Self {
545        let key_by_file_id = stable_key_by_file_id(root, files);
546        let id_by_key: rustc_hash::FxHashMap<_, _> = key_by_file_id
547            .iter()
548            .map(|(file_id, key)| (key.clone(), *file_id))
549            .collect();
550        let by_key: rustc_hash::FxHashMap<_, _> = modules
551            .iter()
552            .filter_map(|module| {
553                key_by_file_id
554                    .get(&module.file_id)
555                    .map(|key| (key.clone(), module))
556            })
557            .collect();
558        let path_by_key: rustc_hash::FxHashMap<_, _> = files
559            .iter()
560            .map(|file| {
561                (
562                    StableFileKey::from_root_relative(root, &file.path),
563                    file.path.clone(),
564                )
565            })
566            .collect();
567
568        Self {
569            file_ids: id_by_key,
570            modules: by_key,
571            paths: path_by_key,
572        }
573    }
574}
575
576fn restore_cached_resolved_module(
577    entry: &CachedResolvedModule,
578    indexes: &mut RestoreResolvedModuleIndexes<'_>,
579) -> Option<ResolvedModule> {
580    let module = indexes.modules.remove(&entry.key)?;
581    let path = indexes.paths.get(&entry.key)?.clone();
582    let resolved_dynamic_pattern_targets =
583        restore_dynamic_pattern_targets(entry, module, &indexes.file_ids)?;
584
585    Some(ResolvedModule {
586        file_id: module.file_id,
587        path,
588        exports: Arc::clone(&module.exports),
589        re_exports: entry
590            .re_exports
591            .iter()
592            .cloned()
593            .map(|re_export| re_export.into_resolved(&indexes.file_ids))
594            .collect::<Option<Vec<_>>>()?,
595        resolved_imports: entry
596            .resolved_imports
597            .iter()
598            .cloned()
599            .map(|import| import.into_resolved(&indexes.file_ids))
600            .collect::<Option<Vec<_>>>()?,
601        resolved_dynamic_imports: entry
602            .resolved_dynamic_imports
603            .iter()
604            .cloned()
605            .map(|import| import.into_resolved(&indexes.file_ids))
606            .collect::<Option<Vec<_>>>()?,
607        resolved_dynamic_patterns: module
608            .dynamic_import_patterns
609            .iter()
610            .cloned()
611            .zip(resolved_dynamic_pattern_targets)
612            .collect(),
613        member_accesses: Arc::clone(&module.member_accesses),
614        semantic_facts: Arc::clone(&module.semantic_facts),
615        whole_object_uses: Arc::clone(&module.whole_object_uses),
616        has_cjs_exports: module.has_cjs_exports,
617        has_angular_component_template_url: module.has_angular_component_template_url,
618        unused_import_bindings: module.unused_import_bindings.iter().cloned().collect(),
619        type_referenced_import_bindings: module.type_referenced_import_bindings.clone(),
620        value_referenced_import_bindings: module.value_referenced_import_bindings.clone(),
621        namespace_object_aliases: module.namespace_object_aliases.clone(),
622        exported_factory_returns: Arc::clone(&module.exported_factory_returns),
623        exported_factory_return_object_shapes: Arc::clone(
624            &module.exported_factory_return_object_shapes,
625        ),
626        type_member_types: Arc::clone(&module.type_member_types),
627    })
628}
629
630fn restore_dynamic_pattern_targets(
631    entry: &CachedResolvedModule,
632    module: &fallow_types::extract::ModuleInfo,
633    id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
634) -> Option<Vec<Vec<FileId>>> {
635    if entry.resolved_dynamic_pattern_targets.len() != module.dynamic_import_patterns.len() {
636        return None;
637    }
638    entry
639        .resolved_dynamic_pattern_targets
640        .iter()
641        .map(|targets| {
642            targets
643                .iter()
644                .map(|key| id_by_key.get(key).copied())
645                .collect::<Option<Vec<_>>>()
646        })
647        .collect()
648}
649
650fn stable_key_by_file_id(
651    root: &Path,
652    files: &[DiscoveredFile],
653) -> rustc_hash::FxHashMap<FileId, StableFileKey> {
654    files
655        .iter()
656        .map(|file| (file.id, StableFileKey::from_root_relative(root, &file.path)))
657        .collect()
658}
659
660fn span_to_pair(span: Span) -> [u32; 2] {
661    [span.start, span.end]
662}
663
664fn pair_to_span(pair: [u32; 2]) -> Span {
665    Span::new(pair[0], pair[1])
666}
667
668/// Serialize an [`oxc_span::Span`] as a `[start, end]` `u32` pair.
669///
670/// `oxc_span::Span` does not enable its own serde feature in this workspace, so
671/// the graph types that carry spans route them through this module via
672/// `#[serde(with = "crate::cache::span_serde")]`. A 2-element array keeps the
673/// postcard encoding compact (two varints) and is trivially lossless: a `Span`
674/// is fully described by its `start` / `end` offsets.
675pub(crate) mod span_serde {
676    use oxc_span::Span;
677    use serde::{Deserialize, Deserializer, Serialize, Serializer};
678
679    #[expect(
680        clippy::trivially_copy_pass_by_ref,
681        reason = "serde `serialize_with` / `with` requires a `&T` signature"
682    )]
683    pub fn serialize<S: Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
684        [span.start, span.end].serialize(serializer)
685    }
686
687    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Span, D::Error> {
688        let [start, end] = <[u32; 2]>::deserialize(deserializer)?;
689        Ok(Span::new(start, end))
690    }
691}
692
693/// Lossless cache (de)serialization for `Vec<MemberInfo>`.
694///
695/// `fallow_types::extract::MemberInfo` derives only `serde::Serialize`, and its
696/// `span` field uses `serialize_with` with no matching deserializer, so it
697/// cannot be deserialized through a plain derive. Rather than change the shared
698/// type's serde shape (which would ripple into JSON output), the cache mirrors
699/// it field-for-field into a dedicated `CachedMemberInfo` and converts both
700/// ways. Every `MemberInfo` field is carried, so the round-trip is lossless.
701pub(crate) mod member_serde {
702    use fallow_types::extract::{MemberInfo, MemberKind};
703    use oxc_span::Span;
704    use serde::{Deserialize, Deserializer, Serialize, Serializer};
705
706    #[derive(Serialize, Deserialize)]
707    struct CachedMemberInfo {
708        name: String,
709        kind: MemberKind,
710        span: [u32; 2],
711        has_decorator: bool,
712        decorator_names: Vec<String>,
713        is_instance_returning_static: bool,
714        is_self_returning: bool,
715    }
716
717    impl From<&MemberInfo> for CachedMemberInfo {
718        fn from(member: &MemberInfo) -> Self {
719            Self {
720                name: member.name.clone(),
721                kind: member.kind,
722                span: [member.span.start, member.span.end],
723                has_decorator: member.has_decorator,
724                decorator_names: member.decorator_names.clone(),
725                is_instance_returning_static: member.is_instance_returning_static,
726                is_self_returning: member.is_self_returning,
727            }
728        }
729    }
730
731    impl From<CachedMemberInfo> for MemberInfo {
732        fn from(cached: CachedMemberInfo) -> Self {
733            Self {
734                name: cached.name,
735                kind: cached.kind,
736                span: Span::new(cached.span[0], cached.span[1]),
737                has_decorator: cached.has_decorator,
738                decorator_names: cached.decorator_names,
739                is_instance_returning_static: cached.is_instance_returning_static,
740                is_self_returning: cached.is_self_returning,
741            }
742        }
743    }
744
745    pub fn serialize<S: Serializer>(
746        members: &[MemberInfo],
747        serializer: S,
748    ) -> Result<S::Ok, S::Error> {
749        let mirror: Vec<CachedMemberInfo> = members.iter().map(CachedMemberInfo::from).collect();
750        mirror.serialize(serializer)
751    }
752
753    pub fn deserialize<'de, D: Deserializer<'de>>(
754        deserializer: D,
755    ) -> Result<Vec<MemberInfo>, D::Error> {
756        let mirror = Vec::<CachedMemberInfo>::deserialize(deserializer)?;
757        Ok(mirror.into_iter().map(MemberInfo::from).collect())
758    }
759}
760
761/// Option dimensions that affect graph construction.
762///
763/// The hashes are intentionally opaque to this crate. Callers decide which
764/// resolver/plugin/entry-point inputs feed each hash, while this contract keeps
765/// graph-cache validation explicit and typed.
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
767pub struct GraphCacheMode {
768    /// Import resolver and tsconfig-relevant options.
769    pub resolver_options_hash: u64,
770    /// Entry point set and reachability root options.
771    pub entry_points_hash: u64,
772    /// Plugin-derived graph-affecting configuration.
773    pub plugin_config_hash: u64,
774}
775
776impl GraphCacheMode {
777    /// Build a mode from explicit hash dimensions.
778    #[must_use]
779    pub const fn new(
780        resolver_options_hash: u64,
781        entry_points_hash: u64,
782        plugin_config_hash: u64,
783    ) -> Self {
784        Self {
785            resolver_options_hash,
786            entry_points_hash,
787            plugin_config_hash,
788        }
789    }
790}
791
792/// Source freshness for one file in a graph-cache manifest.
793#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
794pub struct GraphCacheFile {
795    /// Persistable identity for the file.
796    pub key: StableFileKey,
797    /// Current in-memory identifier for the file.
798    ///
799    /// The stable key is the durable identity, but the persisted `ModuleGraph`
800    /// is still `FileId`-keyed. Until a future graph-cache format remaps graph
801    /// edges through stable keys, a changed assignment must miss rather than
802    /// trust a graph whose `modules[file_id]` indexes point at different files.
803    pub file_id: FileId,
804    /// Metadata fingerprint for cache invalidation.
805    pub fingerprint: SourceFingerprint,
806}
807
808impl GraphCacheFile {
809    /// Build a graph-cache file row from a discovered file and fingerprint.
810    #[must_use]
811    fn from_discovered_file(
812        root: &Path,
813        file: &DiscoveredFile,
814        fingerprint: SourceFingerprint,
815    ) -> Self {
816        Self {
817            key: StableFileKey::from_root_relative(root, &file.path),
818            file_id: file.id,
819            fingerprint,
820        }
821    }
822}
823
824/// Manifest inputs required to trust a persisted graph cache entry.
825#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
826pub struct GraphCacheManifest {
827    /// Schema version used by the persisted graph-cache entry.
828    pub version: u32,
829    /// Graph-affecting option dimensions.
830    pub mode: GraphCacheMode,
831    /// Stable file identities, current FileId assignments, and freshness metadata.
832    pub files: Vec<GraphCacheFile>,
833}
834
835impl GraphCacheManifest {
836    /// Build a manifest and sort files by stable key for deterministic compare.
837    #[must_use]
838    fn new(mode: GraphCacheMode, mut files: Vec<GraphCacheFile>) -> Self {
839        sort_files(&mut files);
840        Self {
841            version: GRAPH_CACHE_VERSION,
842            mode,
843            files,
844        }
845    }
846
847    /// Build a manifest from discovered files plus a fingerprint provider.
848    pub fn from_discovered_files(
849        root: &Path,
850        files: &[DiscoveredFile],
851        mode: GraphCacheMode,
852        mut fingerprint_for_path: impl FnMut(&Path) -> SourceFingerprint,
853    ) -> Self {
854        let rows = files
855            .iter()
856            .map(|file| {
857                GraphCacheFile::from_discovered_file(root, file, fingerprint_for_path(&file.path))
858            })
859            .collect();
860        Self::new(mode, rows)
861    }
862
863    /// True when a persisted manifest matches the current graph inputs.
864    #[must_use]
865    pub fn matches_inputs(&self, current: &Self) -> bool {
866        self.version == GRAPH_CACHE_VERSION
867            && current.version == GRAPH_CACHE_VERSION
868            && self.mode == current.mode
869            && self.files == current.files
870    }
871
872    /// True when a persisted resolver payload can be remapped to current FileIds.
873    ///
874    /// Unlike [`Self::matches_inputs`], this intentionally ignores each row's
875    /// `file_id`. It is not sufficient to trust the persisted `ModuleGraph`, but
876    /// it is sufficient to reuse stable-keyed resolver output and rebuild the
877    /// graph with current FileIds.
878    #[must_use]
879    pub fn matches_resolution_inputs(&self, current: &Self) -> bool {
880        self.version == GRAPH_CACHE_VERSION
881            && current.version == GRAPH_CACHE_VERSION
882            && self.mode == current.mode
883            && self.files.len() == current.files.len()
884            && self
885                .files
886                .iter()
887                .zip(current.files.iter())
888                .all(|(cached, current)| {
889                    cached.key == current.key && cached.fingerprint == current.fingerprint
890                })
891    }
892}
893
894fn sort_files(files: &mut [GraphCacheFile]) {
895    files.sort_unstable_by(|a, b| a.key.cmp(&b.key));
896}
897
898#[cfg(test)]
899mod tests {
900    use std::path::{Path, PathBuf};
901
902    use fallow_types::discover::FileId;
903    use rustc_hash::FxHashMap;
904
905    use super::*;
906
907    fn file(id: u32, path: &str) -> DiscoveredFile {
908        DiscoveredFile {
909            id: FileId(id),
910            path: PathBuf::from(path),
911            size_bytes: 1,
912        }
913    }
914
915    fn mode() -> GraphCacheMode {
916        GraphCacheMode::new(1, 2, 3)
917    }
918
919    fn fingerprints(pairs: &[(&str, SourceFingerprint)]) -> FxHashMap<PathBuf, SourceFingerprint> {
920        pairs
921            .iter()
922            .map(|(path, fingerprint)| (PathBuf::from(path), *fingerprint))
923            .collect()
924    }
925
926    fn manifest(
927        files: &[DiscoveredFile],
928        mode: GraphCacheMode,
929        map: &FxHashMap<PathBuf, SourceFingerprint>,
930    ) -> GraphCacheManifest {
931        GraphCacheManifest::from_discovered_files(Path::new("/project"), files, mode, |path| {
932            *map.get(path).unwrap()
933        })
934    }
935
936    fn import_info(source: &str) -> ImportInfo {
937        ImportInfo {
938            source: source.to_string(),
939            imported_name: fallow_types::extract::ImportedName::SideEffect,
940            local_name: String::new(),
941            is_type_only: false,
942            from_style: false,
943            span: Span::new(0, 0),
944            source_span: Span::new(0, 0),
945        }
946    }
947
948    #[test]
949    fn manifest_sorts_by_stable_file_key() {
950        let files = vec![file(0, "/project/src/z.ts"), file(1, "/project/src/a.ts")];
951        let map = fingerprints(&[
952            ("/project/src/z.ts", SourceFingerprint::new(10, 1)),
953            ("/project/src/a.ts", SourceFingerprint::new(20, 1)),
954        ]);
955
956        let manifest = manifest(&files, mode(), &map);
957
958        let keys: Vec<&str> = manifest
959            .files
960            .iter()
961            .map(|file| file.key.as_str())
962            .collect();
963        assert_eq!(keys, vec!["src/a.ts", "src/z.ts"]);
964    }
965
966    #[test]
967    fn manifest_misses_on_file_id_shift_until_graph_remap_exists() {
968        let before = vec![file(0, "/project/src/a.ts"), file(1, "/project/src/c.ts")];
969        let after = vec![file(9, "/project/src/c.ts"), file(2, "/project/src/a.ts")];
970        let map = fingerprints(&[
971            ("/project/src/a.ts", SourceFingerprint::new(10, 1)),
972            ("/project/src/c.ts", SourceFingerprint::new(20, 1)),
973        ]);
974
975        let cached = manifest(&before, mode(), &map);
976        let current = manifest(&after, mode(), &map);
977
978        assert!(
979            !cached.matches_inputs(&current),
980            "the persisted graph is still FileId-keyed, so FileId shifts cannot trust it"
981        );
982        assert!(
983            cached.matches_resolution_inputs(&current),
984            "stable-keyed resolver payloads may be remapped across FileId shifts"
985        );
986    }
987
988    #[test]
989    fn cached_resolve_result_remaps_internal_targets_by_stable_key() {
990        let key_a = StableFileKey::from_root_relative(
991            Path::new("/project"),
992            Path::new("/project/src/a.ts"),
993        );
994        let key_b = StableFileKey::from_root_relative(
995            Path::new("/project"),
996            Path::new("/project/src/b.ts"),
997        );
998        let key_by_file_id =
999            FxHashMap::from_iter([(FileId(0), key_a.clone()), (FileId(1), key_b.clone())]);
1000        let id_by_key = FxHashMap::from_iter([(key_a, FileId(7)), (key_b, FileId(9))]);
1001
1002        let cached = CachedResolveResult::from_resolve_result(
1003            &ResolveResult::InternalPackageModule {
1004                file_id: FileId(1),
1005                package_name: "@scope/pkg".to_string(),
1006            },
1007            &key_by_file_id,
1008        )
1009        .expect("target file id should map to a stable key");
1010
1011        let restored = cached
1012            .into_resolve_result(&id_by_key)
1013            .expect("stable key should map to current FileId");
1014
1015        assert!(matches!(
1016            restored,
1017            ResolveResult::InternalPackageModule {
1018                file_id: FileId(9),
1019                ref package_name,
1020            } if package_name == "@scope/pkg"
1021        ));
1022    }
1023
1024    #[test]
1025    fn cached_resolve_result_preserves_commonjs_provenance() {
1026        let key = StableFileKey::from_root_relative(
1027            Path::new("/project"),
1028            Path::new("/project/src/dependency.ts"),
1029        );
1030        let key_by_file_id = FxHashMap::from_iter([(FileId(3), key.clone())]);
1031        let id_by_key = FxHashMap::from_iter([(key, FileId(8))]);
1032
1033        let cached = CachedResolveResult::from_resolve_result(
1034            &ResolveResult::CommonJsInternalModule(FileId(3)),
1035            &key_by_file_id,
1036        )
1037        .expect("CommonJS target should map to a stable key");
1038        let restored = cached
1039            .into_resolve_result(&id_by_key)
1040            .expect("stable key should map to the current FileId");
1041
1042        assert!(matches!(
1043            restored,
1044            ResolveResult::CommonJsInternalModule(FileId(8))
1045        ));
1046    }
1047
1048    #[test]
1049    fn cached_resolve_result_preserves_commonjs_bare_package_provenance() {
1050        let cached = CachedResolveResult::from_resolve_result(
1051            &ResolveResult::CommonJsNpmPackage("shared-package".to_string()),
1052            &FxHashMap::default(),
1053        )
1054        .expect("bare CommonJS package should not need a stable file key");
1055        let restored = cached
1056            .into_resolve_result(&FxHashMap::default())
1057            .expect("bare CommonJS package should restore without a file map");
1058
1059        assert!(matches!(
1060            restored,
1061            ResolveResult::CommonJsNpmPackage(package_name)
1062                if package_name == "shared-package"
1063        ));
1064    }
1065
1066    #[test]
1067    fn cache_resolved_project_rejects_unknown_internal_targets() {
1068        let files = vec![file(0, "/project/src/a.ts")];
1069        let module = ResolvedModule {
1070            file_id: FileId(0),
1071            path: PathBuf::from("/project/src/a.ts"),
1072            resolved_imports: vec![ResolvedImport {
1073                info: import_info("./missing"),
1074                target: ResolveResult::InternalModule(FileId(1)),
1075            }],
1076            ..ResolvedModule::default()
1077        };
1078        let project = ResolvedProject {
1079            modules: vec![module],
1080            replaced_module_targets: Vec::new(),
1081        };
1082
1083        let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1084
1085        assert!(cached.is_none());
1086    }
1087
1088    #[test]
1089    fn cached_replaced_target_remaps_both_file_ids_by_stable_key() {
1090        let source_key = StableFileKey::from_root_relative(
1091            Path::new("/project"),
1092            Path::new("/project/src/example.test.ts"),
1093        );
1094        let target_key = StableFileKey::from_root_relative(
1095            Path::new("/project"),
1096            Path::new("/project/src/dependency.ts"),
1097        );
1098        let key_by_file_id = FxHashMap::from_iter([
1099            (FileId(2), source_key.clone()),
1100            (FileId(3), target_key.clone()),
1101        ]);
1102        let id_by_key = FxHashMap::from_iter([(source_key, FileId(8)), (target_key, FileId(9))]);
1103        let resolved = ResolvedReplacedModuleTarget {
1104            source_file: FileId(2),
1105            target_file: FileId(3),
1106        };
1107
1108        let cached = CachedResolvedReplacedModuleTarget::from_resolved(resolved, &key_by_file_id)
1109            .expect("both file ids should map to stable keys");
1110        let restored = cached
1111            .into_resolved(&id_by_key)
1112            .expect("both stable keys should map to current file ids");
1113
1114        assert_eq!(
1115            restored,
1116            ResolvedReplacedModuleTarget {
1117                source_file: FileId(8),
1118                target_file: FileId(9),
1119            }
1120        );
1121    }
1122
1123    #[test]
1124    fn cache_resolved_project_rejects_unknown_replacement_targets() {
1125        let files = vec![file(0, "/project/src/example.test.ts")];
1126        let project = ResolvedProject {
1127            modules: vec![ResolvedModule {
1128                file_id: FileId(0),
1129                path: PathBuf::from("/project/src/example.test.ts"),
1130                ..ResolvedModule::default()
1131            }],
1132            replaced_module_targets: vec![ResolvedReplacedModuleTarget {
1133                source_file: FileId(0),
1134                target_file: FileId(1),
1135            }],
1136        };
1137
1138        let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1139
1140        assert!(cached.is_none());
1141    }
1142
1143    #[test]
1144    fn manifest_misses_on_fingerprint_change() {
1145        let files = vec![file(0, "/project/src/a.ts")];
1146        let cached_map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
1147        let current_map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(11, 1))]);
1148
1149        let cached = manifest(&files, mode(), &cached_map);
1150        let current = manifest(&files, mode(), &current_map);
1151
1152        assert!(!cached.matches_inputs(&current));
1153    }
1154
1155    #[test]
1156    fn manifest_misses_on_file_deletion() {
1157        let before = vec![
1158            file(0, "/project/src/a.ts"),
1159            file(1, "/project/src/deleted.ts"),
1160        ];
1161        let after = vec![file(0, "/project/src/a.ts")];
1162        let map = fingerprints(&[
1163            ("/project/src/a.ts", SourceFingerprint::new(10, 1)),
1164            ("/project/src/deleted.ts", SourceFingerprint::new(20, 1)),
1165        ]);
1166
1167        let cached = manifest(&before, mode(), &map);
1168        let current = manifest(&after, mode(), &map);
1169
1170        assert!(!cached.matches_inputs(&current));
1171    }
1172
1173    #[test]
1174    fn manifest_misses_on_file_rename_with_same_fingerprint() {
1175        let before = vec![file(0, "/project/src/old.ts")];
1176        let after = vec![file(0, "/project/src/new.ts")];
1177        let map = fingerprints(&[
1178            ("/project/src/old.ts", SourceFingerprint::new(10, 1)),
1179            ("/project/src/new.ts", SourceFingerprint::new(10, 1)),
1180        ]);
1181
1182        let cached = manifest(&before, mode(), &map);
1183        let current = manifest(&after, mode(), &map);
1184
1185        assert!(!cached.matches_inputs(&current));
1186    }
1187
1188    #[test]
1189    fn manifest_misses_on_workspace_scoped_file_set() {
1190        let full_project = vec![
1191            file(0, "/project/packages/app/src/index.ts"),
1192            file(1, "/project/packages/shared/src/index.ts"),
1193        ];
1194        let workspace_scoped = vec![file(0, "/project/packages/app/src/index.ts")];
1195        let map = fingerprints(&[
1196            (
1197                "/project/packages/app/src/index.ts",
1198                SourceFingerprint::new(10, 1),
1199            ),
1200            (
1201                "/project/packages/shared/src/index.ts",
1202                SourceFingerprint::new(20, 1),
1203            ),
1204        ]);
1205
1206        let cached = manifest(&full_project, mode(), &map);
1207        let current = manifest(&workspace_scoped, mode(), &map);
1208
1209        assert!(!cached.matches_inputs(&current));
1210        assert!(!cached.matches_resolution_inputs(&current));
1211    }
1212
1213    #[test]
1214    fn manifest_misses_on_mode_change() {
1215        let files = vec![file(0, "/project/src/a.ts")];
1216        let map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
1217
1218        let cached = manifest(&files, mode(), &map);
1219        let current = manifest(&files, GraphCacheMode::new(1, 99, 3), &map);
1220
1221        assert!(!cached.matches_inputs(&current));
1222    }
1223
1224    #[test]
1225    fn manifest_misses_on_version_change() {
1226        let files = vec![file(0, "/project/src/a.ts")];
1227        let map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
1228        let mut cached = manifest(&files, mode(), &map);
1229        let current = manifest(&files, mode(), &map);
1230
1231        cached.version = GRAPH_CACHE_VERSION + 1;
1232
1233        assert!(!cached.matches_inputs(&current));
1234    }
1235}