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