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