Skip to main content

fallow_graph/cache/
mod.rs

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