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