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