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