Skip to main content

fallow_graph/cache/
mod.rs

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