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