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