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