use std::sync::Arc;
use std::path::{Path, PathBuf};
use fallow_types::cache_rejection::CacheRejection;
use fallow_types::discover::{DiscoveredFile, FileId, StableFileKey};
use fallow_types::extract::{ImportInfo, ReExportInfo};
use oxc_span::Span;
use crate::resolve::{
ResolveResult, ResolvedImport, ResolvedModule, ResolvedProject, ResolvedReExport,
ResolvedReplacedModuleTarget,
};
mod store;
pub use store::{GRAPH_CACHE_FILE, GraphCacheStore};
pub const GRAPH_CACHE_VERSION: u32 = 56;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum CachedResolveResult {
InternalModule(StableFileKey),
CommonJsInternalModule(StableFileKey),
SyntheticAutoImport(StableFileKey),
InternalPackageModule {
key: StableFileKey,
package_name: String,
},
CommonJsInternalPackageModule {
key: StableFileKey,
package_name: String,
},
ExternalFile(PathBuf),
NpmPackage(String),
CommonJsNpmPackage(String),
Unresolvable(String),
}
impl CachedResolveResult {
fn from_resolve_result(
target: &ResolveResult,
key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
) -> Option<Self> {
Some(match target {
ResolveResult::InternalModule(file_id) => {
Self::InternalModule(key_by_file_id.get(file_id)?.clone())
}
ResolveResult::CommonJsInternalModule(file_id) => {
Self::CommonJsInternalModule(key_by_file_id.get(file_id)?.clone())
}
ResolveResult::SyntheticAutoImport(file_id) => {
Self::SyntheticAutoImport(key_by_file_id.get(file_id)?.clone())
}
ResolveResult::InternalPackageModule {
file_id,
package_name,
} => Self::InternalPackageModule {
key: key_by_file_id.get(file_id)?.clone(),
package_name: package_name.clone(),
},
ResolveResult::CommonJsInternalPackageModule {
file_id,
package_name,
} => Self::CommonJsInternalPackageModule {
key: key_by_file_id.get(file_id)?.clone(),
package_name: package_name.clone(),
},
ResolveResult::ExternalFile(path) => Self::ExternalFile(path.clone()),
ResolveResult::NpmPackage(package_name) => Self::NpmPackage(package_name.clone()),
ResolveResult::CommonJsNpmPackage(package_name) => {
Self::CommonJsNpmPackage(package_name.clone())
}
ResolveResult::Unresolvable(specifier) => Self::Unresolvable(specifier.clone()),
})
}
fn into_resolve_result(
self,
id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
) -> Option<ResolveResult> {
Some(match self {
Self::InternalModule(key) => ResolveResult::InternalModule(*id_by_key.get(&key)?),
Self::CommonJsInternalModule(key) => {
ResolveResult::CommonJsInternalModule(*id_by_key.get(&key)?)
}
Self::SyntheticAutoImport(key) => {
ResolveResult::SyntheticAutoImport(*id_by_key.get(&key)?)
}
Self::InternalPackageModule { key, package_name } => {
ResolveResult::InternalPackageModule {
file_id: *id_by_key.get(&key)?,
package_name,
}
}
Self::CommonJsInternalPackageModule { key, package_name } => {
ResolveResult::CommonJsInternalPackageModule {
file_id: *id_by_key.get(&key)?,
package_name,
}
}
Self::ExternalFile(path) => ResolveResult::ExternalFile(path),
Self::NpmPackage(package_name) => ResolveResult::NpmPackage(package_name),
Self::CommonJsNpmPackage(package_name) => {
ResolveResult::CommonJsNpmPackage(package_name)
}
Self::Unresolvable(specifier) => ResolveResult::Unresolvable(specifier),
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedResolvedImport {
info: CachedImportInfo,
target: CachedResolveResult,
}
impl CachedResolvedImport {
fn from_resolved(
import: &ResolvedImport,
key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
) -> Option<Self> {
Some(Self {
info: CachedImportInfo::from(&import.info),
target: CachedResolveResult::from_resolve_result(&import.target, key_by_file_id)?,
})
}
fn into_resolved(
self,
id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
) -> Option<ResolvedImport> {
Some(ResolvedImport {
info: self.info.into(),
target: self.target.into_resolve_result(id_by_key)?,
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedResolvedReExport {
info: CachedReExportInfo,
target: CachedResolveResult,
}
impl CachedResolvedReExport {
fn from_resolved(
re_export: &ResolvedReExport,
key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
) -> Option<Self> {
Some(Self {
info: CachedReExportInfo::from(&re_export.info),
target: CachedResolveResult::from_resolve_result(&re_export.target, key_by_file_id)?,
})
}
fn into_resolved(
self,
id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
) -> Option<ResolvedReExport> {
Some(ResolvedReExport {
info: self.info.into(),
target: self.target.into_resolve_result(id_by_key)?,
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedImportInfo {
source: String,
imported_name: fallow_types::extract::ImportedName,
local_name: String,
is_type_only: bool,
is_type_only_star: bool,
from_style: bool,
span: [u32; 2],
source_span: [u32; 2],
}
impl From<&ImportInfo> for CachedImportInfo {
fn from(info: &ImportInfo) -> Self {
Self {
source: info.source.clone(),
imported_name: info.imported_name.clone(),
local_name: info.local_name.clone(),
is_type_only: info.is_type_only,
is_type_only_star: info.is_type_only_star,
from_style: info.from_style,
span: span_to_pair(info.span),
source_span: span_to_pair(info.source_span),
}
}
}
impl From<CachedImportInfo> for ImportInfo {
fn from(info: CachedImportInfo) -> Self {
Self {
source: info.source,
imported_name: info.imported_name,
local_name: info.local_name,
is_type_only: info.is_type_only,
is_type_only_star: info.is_type_only_star,
from_style: info.from_style,
span: pair_to_span(info.span),
source_span: pair_to_span(info.source_span),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedReExportInfo {
source: String,
imported_name: String,
exported_name: String,
is_type_only: bool,
span: [u32; 2],
statement_span: [u32; 2],
source_span: [u32; 2],
}
impl From<&ReExportInfo> for CachedReExportInfo {
fn from(info: &ReExportInfo) -> Self {
Self {
source: info.source.clone(),
imported_name: info.imported_name.clone(),
exported_name: info.exported_name.clone(),
is_type_only: info.is_type_only,
span: span_to_pair(info.span),
statement_span: span_to_pair(info.statement_span),
source_span: span_to_pair(info.source_span),
}
}
}
impl From<CachedReExportInfo> for ReExportInfo {
fn from(info: CachedReExportInfo) -> Self {
Self {
source: info.source,
imported_name: info.imported_name,
exported_name: info.exported_name,
is_type_only: info.is_type_only,
span: pair_to_span(info.span),
statement_span: pair_to_span(info.statement_span),
source_span: pair_to_span(info.source_span),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedResolvedModule {
key: StableFileKey,
resolved_imports: Vec<CachedResolvedImport>,
resolved_dynamic_imports: Vec<CachedResolvedImport>,
re_exports: Vec<CachedResolvedReExport>,
resolved_dynamic_pattern_targets: Vec<Vec<StableFileKey>>,
}
impl CachedResolvedModule {
fn from_resolved(
module: &ResolvedModule,
key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
) -> Option<Self> {
Some(Self {
key: key_by_file_id.get(&module.file_id)?.clone(),
resolved_imports: module
.resolved_imports
.iter()
.map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
.collect::<Option<Vec<_>>>()?,
resolved_dynamic_imports: module
.resolved_dynamic_imports
.iter()
.map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
.collect::<Option<Vec<_>>>()?,
re_exports: module
.re_exports
.iter()
.map(|re_export| CachedResolvedReExport::from_resolved(re_export, key_by_file_id))
.collect::<Option<Vec<_>>>()?,
resolved_dynamic_pattern_targets: module
.resolved_dynamic_patterns
.iter()
.map(|(_, targets)| {
targets
.iter()
.map(|target| key_by_file_id.get(target).cloned())
.collect::<Option<Vec<_>>>()
})
.collect::<Option<Vec<_>>>()?,
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CachedResolvedReplacedModuleTarget {
source_key: StableFileKey,
target_key: StableFileKey,
}
impl CachedResolvedReplacedModuleTarget {
fn from_resolved(
target: ResolvedReplacedModuleTarget,
key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
) -> Option<Self> {
Some(Self {
source_key: key_by_file_id.get(&target.source_file)?.clone(),
target_key: key_by_file_id.get(&target.target_file)?.clone(),
})
}
fn into_resolved(
self,
id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
) -> Option<ResolvedReplacedModuleTarget> {
Some(ResolvedReplacedModuleTarget {
source_file: *id_by_key.get(&self.source_key)?,
target_file: *id_by_key.get(&self.target_key)?,
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedResolvedProject {
modules: Vec<CachedResolvedModule>,
replaced_module_targets: Vec<CachedResolvedReplacedModuleTarget>,
}
#[must_use]
pub fn cache_resolved_project(
root: &Path,
files: &[DiscoveredFile],
resolved: &ResolvedProject,
) -> Option<CachedResolvedProject> {
let key_by_file_id = stable_key_by_file_id(root, files);
let modules = resolved
.modules
.iter()
.map(|module| CachedResolvedModule::from_resolved(module, &key_by_file_id))
.collect::<Option<Vec<_>>>()?;
let replaced_module_targets = resolved
.replaced_module_targets
.iter()
.copied()
.map(|target| CachedResolvedReplacedModuleTarget::from_resolved(target, &key_by_file_id))
.collect::<Option<Vec<_>>>()?;
Some(CachedResolvedProject {
modules,
replaced_module_targets,
})
}
#[must_use]
pub fn restore_resolved_project(
root: &Path,
modules: &[fallow_types::extract::ModuleInfo],
files: &[DiscoveredFile],
cached: &CachedResolvedProject,
) -> Option<ResolvedProject> {
if modules.len() != cached.modules.len() {
return None;
}
let mut indexes = RestoreResolvedModuleIndexes::new(root, modules, files);
let resolved_modules = cached
.modules
.iter()
.map(|entry| restore_cached_resolved_module(entry, &mut indexes))
.collect::<Option<Vec<_>>>()?;
let mut replaced_module_targets = cached
.replaced_module_targets
.iter()
.cloned()
.map(|target| target.into_resolved(&indexes.file_ids))
.collect::<Option<Vec<_>>>()?;
replaced_module_targets
.sort_unstable_by_key(|target| (target.source_file.0, target.target_file.0));
replaced_module_targets.dedup();
Some(ResolvedProject {
modules: resolved_modules,
replaced_module_targets,
work: crate::resolve::ResolveWork::default(),
})
}
struct RestoreResolvedModuleIndexes<'a> {
file_ids: rustc_hash::FxHashMap<StableFileKey, FileId>,
modules: rustc_hash::FxHashMap<StableFileKey, &'a fallow_types::extract::ModuleInfo>,
paths: rustc_hash::FxHashMap<StableFileKey, std::path::PathBuf>,
}
impl<'a> RestoreResolvedModuleIndexes<'a> {
fn new(
root: &Path,
modules: &'a [fallow_types::extract::ModuleInfo],
files: &[DiscoveredFile],
) -> Self {
let key_by_file_id = stable_key_by_file_id(root, files);
let id_by_key: rustc_hash::FxHashMap<_, _> = key_by_file_id
.iter()
.map(|(file_id, key)| (key.clone(), *file_id))
.collect();
let by_key: rustc_hash::FxHashMap<_, _> = modules
.iter()
.filter_map(|module| {
key_by_file_id
.get(&module.file_id)
.map(|key| (key.clone(), module))
})
.collect();
let path_by_key: rustc_hash::FxHashMap<_, _> = files
.iter()
.map(|file| {
(
StableFileKey::from_root_relative(root, &file.path),
file.path.clone(),
)
})
.collect();
Self {
file_ids: id_by_key,
modules: by_key,
paths: path_by_key,
}
}
}
fn restore_cached_resolved_module(
entry: &CachedResolvedModule,
indexes: &mut RestoreResolvedModuleIndexes<'_>,
) -> Option<ResolvedModule> {
let module = indexes.modules.remove(&entry.key)?;
let path = indexes.paths.get(&entry.key)?.clone();
let resolved_dynamic_pattern_targets =
restore_dynamic_pattern_targets(entry, module, &indexes.file_ids)?;
Some(ResolvedModule {
file_id: module.file_id,
path,
exports: Arc::clone(&module.exports),
re_exports: entry
.re_exports
.iter()
.cloned()
.map(|re_export| re_export.into_resolved(&indexes.file_ids))
.collect::<Option<Vec<_>>>()?,
resolved_imports: entry
.resolved_imports
.iter()
.cloned()
.map(|import| import.into_resolved(&indexes.file_ids))
.collect::<Option<Vec<_>>>()?,
resolved_dynamic_imports: entry
.resolved_dynamic_imports
.iter()
.cloned()
.map(|import| import.into_resolved(&indexes.file_ids))
.collect::<Option<Vec<_>>>()?,
resolved_dynamic_patterns: module
.dynamic_import_patterns
.iter()
.cloned()
.zip(resolved_dynamic_pattern_targets)
.collect(),
member_accesses: Arc::clone(&module.member_accesses),
semantic_facts: Arc::clone(&module.semantic_facts),
whole_object_uses: Arc::clone(&module.whole_object_uses),
has_cjs_exports: module.has_cjs_exports,
has_angular_component_template_url: module.has_angular_component_template_url,
unused_import_bindings: module.unused_import_bindings.iter().cloned().collect(),
type_referenced_import_bindings: module.type_referenced_import_bindings.clone(),
value_referenced_import_bindings: module.value_referenced_import_bindings.clone(),
namespace_object_aliases: module.namespace_object_aliases.clone(),
exported_factory_returns: Arc::clone(&module.exported_factory_returns),
exported_factory_return_object_shapes: Arc::clone(
&module.exported_factory_return_object_shapes,
),
type_member_types: Arc::clone(&module.type_member_types),
})
}
fn restore_dynamic_pattern_targets(
entry: &CachedResolvedModule,
module: &fallow_types::extract::ModuleInfo,
id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
) -> Option<Vec<Vec<FileId>>> {
if entry.resolved_dynamic_pattern_targets.len() != module.dynamic_import_patterns.len() {
return None;
}
entry
.resolved_dynamic_pattern_targets
.iter()
.map(|targets| {
targets
.iter()
.map(|key| id_by_key.get(key).copied())
.collect::<Option<Vec<_>>>()
})
.collect()
}
fn stable_key_by_file_id(
root: &Path,
files: &[DiscoveredFile],
) -> rustc_hash::FxHashMap<FileId, StableFileKey> {
files
.iter()
.map(|file| (file.id, StableFileKey::from_root_relative(root, &file.path)))
.collect()
}
fn span_to_pair(span: Span) -> [u32; 2] {
[span.start, span.end]
}
fn pair_to_span(pair: [u32; 2]) -> Span {
Span::new(pair[0], pair[1])
}
pub(crate) mod span_serde {
use oxc_span::Span;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde `serialize_with` / `with` requires a `&T` signature"
)]
pub fn serialize<S: Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
[span.start, span.end].serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Span, D::Error> {
let [start, end] = <[u32; 2]>::deserialize(deserializer)?;
Ok(Span::new(start, end))
}
}
pub(crate) mod member_serde {
use fallow_types::extract::{MemberInfo, MemberKind};
use oxc_span::Span;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
struct CachedMemberInfo {
name: String,
kind: MemberKind,
span: [u32; 2],
has_decorator: bool,
decorator_names: Vec<String>,
is_instance_returning_static: bool,
is_self_returning: bool,
}
impl From<&MemberInfo> for CachedMemberInfo {
fn from(member: &MemberInfo) -> Self {
Self {
name: member.name.clone(),
kind: member.kind,
span: [member.span.start, member.span.end],
has_decorator: member.has_decorator,
decorator_names: member.decorator_names.clone(),
is_instance_returning_static: member.is_instance_returning_static,
is_self_returning: member.is_self_returning,
}
}
}
impl From<CachedMemberInfo> for MemberInfo {
fn from(cached: CachedMemberInfo) -> Self {
Self {
name: cached.name,
kind: cached.kind,
span: Span::new(cached.span[0], cached.span[1]),
has_decorator: cached.has_decorator,
decorator_names: cached.decorator_names,
is_instance_returning_static: cached.is_instance_returning_static,
is_self_returning: cached.is_self_returning,
}
}
}
pub fn serialize<S: Serializer>(
members: &[MemberInfo],
serializer: S,
) -> Result<S::Ok, S::Error> {
let mirror: Vec<CachedMemberInfo> = members.iter().map(CachedMemberInfo::from).collect();
mirror.serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<MemberInfo>, D::Error> {
let mirror = Vec::<CachedMemberInfo>::deserialize(deserializer)?;
Ok(mirror.into_iter().map(MemberInfo::from).collect())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct GraphCacheMode {
pub resolver_options_hash: u64,
pub entry_points_hash: u64,
pub plugin_config_hash: u64,
}
impl GraphCacheMode {
#[must_use]
pub const fn new(
resolver_options_hash: u64,
entry_points_hash: u64,
plugin_config_hash: u64,
) -> Self {
Self {
resolver_options_hash,
entry_points_hash,
plugin_config_hash,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct GraphCacheFile {
pub key: StableFileKey,
pub file_id: FileId,
pub content_hash: u64,
}
impl GraphCacheFile {
#[must_use]
fn from_discovered_file(root: &Path, file: &DiscoveredFile, content_hash: u64) -> Self {
Self {
key: StableFileKey::from_root_relative(root, &file.path),
file_id: file.id,
content_hash,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GraphCacheManifest {
pub root: PathBuf,
pub version: u32,
pub mode: GraphCacheMode,
pub files: Vec<GraphCacheFile>,
}
impl GraphCacheManifest {
#[must_use]
fn new(root: &Path, mode: GraphCacheMode, mut files: Vec<GraphCacheFile>) -> Self {
sort_files(&mut files);
Self {
root: root.to_path_buf(),
version: GRAPH_CACHE_VERSION,
mode,
files,
}
}
pub fn from_discovered_files(
root: &Path,
files: &[DiscoveredFile],
mode: GraphCacheMode,
mut content_hash_for_file: impl FnMut(&DiscoveredFile) -> u64,
) -> Self {
let rows = files
.iter()
.map(|file| {
GraphCacheFile::from_discovered_file(root, file, content_hash_for_file(file))
})
.collect();
Self::new(root, mode, rows)
}
#[must_use]
pub fn matches_inputs(&self, current: &Self) -> bool {
self.version == GRAPH_CACHE_VERSION
&& current.version == GRAPH_CACHE_VERSION
&& self.root == current.root
&& self.mode == current.mode
&& self.files == current.files
}
#[must_use]
pub fn classify_resolution_mismatch(&self, current: &Self) -> Option<CacheRejection> {
if self.version != GRAPH_CACHE_VERSION || current.version != GRAPH_CACHE_VERSION {
return Some(CacheRejection::VersionMismatch);
}
if self.root != current.root {
return Some(CacheRejection::RootMismatch);
}
if self.mode != current.mode {
return Some(CacheRejection::ModeMismatch);
}
if self.files.len() != current.files.len()
|| self
.files
.iter()
.zip(current.files.iter())
.any(|(cached, current)| cached.key != current.key)
{
return Some(CacheRejection::FileSetChanged);
}
self.files
.iter()
.zip(current.files.iter())
.any(|(cached, current)| cached.content_hash != current.content_hash)
.then_some(CacheRejection::FingerprintChanged)
}
}
fn sort_files(files: &mut [GraphCacheFile]) {
files.sort_unstable_by(|a, b| a.key.cmp(&b.key));
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use fallow_types::discover::FileId;
use fallow_types::extract::{DynamicImportPattern, ModuleInfo, ModuleLoadMechanism};
use rustc_hash::FxHashMap;
use super::*;
fn file(id: u32, path: &str) -> DiscoveredFile {
DiscoveredFile {
id: FileId(id),
path: PathBuf::from(path),
size_bytes: 1,
}
}
fn mode() -> GraphCacheMode {
GraphCacheMode::new(1, 2, 3)
}
fn content_hashes(pairs: &[(&str, u64)]) -> FxHashMap<PathBuf, u64> {
pairs
.iter()
.map(|(path, hash)| (PathBuf::from(path), *hash))
.collect()
}
fn manifest(
files: &[DiscoveredFile],
mode: GraphCacheMode,
map: &FxHashMap<PathBuf, u64>,
) -> GraphCacheManifest {
GraphCacheManifest::from_discovered_files(Path::new("/project"), files, mode, |file| {
*map.get(&file.path).unwrap()
})
}
fn import_info(source: &str) -> ImportInfo {
ImportInfo {
source: source.to_string(),
imported_name: fallow_types::extract::ImportedName::SideEffect,
local_name: String::new(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: Span::new(0, 0),
source_span: Span::new(0, 0),
}
}
#[test]
fn cached_import_info_round_trip_keeps_every_field() {
let original = ImportInfo {
source: "./impl".to_string(),
imported_name: fallow_types::extract::ImportedName::Namespace,
local_name: "ns".to_string(),
is_type_only: true,
is_type_only_star: true,
from_style: true,
span: Span::new(3, 41),
source_span: Span::new(17, 25),
};
let restored = ImportInfo::from(CachedImportInfo::from(&original));
assert_eq!(format!("{restored:?}"), format!("{original:?}"));
}
#[test]
fn manifest_sorts_by_stable_file_key() {
let files = vec![file(0, "/project/src/z.ts"), file(1, "/project/src/a.ts")];
let map = content_hashes(&[("/project/src/z.ts", 10), ("/project/src/a.ts", 20)]);
let manifest = manifest(&files, mode(), &map);
let keys: Vec<&str> = manifest
.files
.iter()
.map(|file| file.key.as_str())
.collect();
assert_eq!(keys, vec!["src/a.ts", "src/z.ts"]);
}
#[test]
fn manifest_misses_on_file_id_shift_until_graph_remap_exists() {
let before = vec![file(0, "/project/src/a.ts"), file(1, "/project/src/c.ts")];
let after = vec![file(9, "/project/src/c.ts"), file(2, "/project/src/a.ts")];
let map = content_hashes(&[("/project/src/a.ts", 10), ("/project/src/c.ts", 20)]);
let cached = manifest(&before, mode(), &map);
let current = manifest(&after, mode(), &map);
assert!(
!cached.matches_inputs(¤t),
"the persisted graph is still FileId-keyed, so FileId shifts cannot trust it"
);
assert_eq!(
cached.classify_resolution_mismatch(¤t),
None,
"stable-keyed resolver payloads may be remapped across FileId shifts"
);
}
#[test]
fn cached_resolve_result_remaps_internal_targets_by_stable_key() {
let key_a = StableFileKey::from_root_relative(
Path::new("/project"),
Path::new("/project/src/a.ts"),
);
let key_b = StableFileKey::from_root_relative(
Path::new("/project"),
Path::new("/project/src/b.ts"),
);
let key_by_file_id =
FxHashMap::from_iter([(FileId(0), key_a.clone()), (FileId(1), key_b.clone())]);
let id_by_key = FxHashMap::from_iter([(key_a, FileId(7)), (key_b, FileId(9))]);
let cached = CachedResolveResult::from_resolve_result(
&ResolveResult::InternalPackageModule {
file_id: FileId(1),
package_name: "@scope/pkg".to_string(),
},
&key_by_file_id,
)
.expect("target file id should map to a stable key");
let restored = cached
.into_resolve_result(&id_by_key)
.expect("stable key should map to current FileId");
assert!(matches!(
restored,
ResolveResult::InternalPackageModule {
file_id: FileId(9),
ref package_name,
} if package_name == "@scope/pkg"
));
}
#[test]
fn cached_resolve_result_preserves_commonjs_provenance() {
let key = StableFileKey::from_root_relative(
Path::new("/project"),
Path::new("/project/src/dependency.ts"),
);
let key_by_file_id = FxHashMap::from_iter([(FileId(3), key.clone())]);
let id_by_key = FxHashMap::from_iter([(key, FileId(8))]);
let cached = CachedResolveResult::from_resolve_result(
&ResolveResult::CommonJsInternalModule(FileId(3)),
&key_by_file_id,
)
.expect("CommonJS target should map to a stable key");
let restored = cached
.into_resolve_result(&id_by_key)
.expect("stable key should map to the current FileId");
assert!(matches!(
restored,
ResolveResult::CommonJsInternalModule(FileId(8))
));
}
#[test]
fn cached_resolve_result_preserves_commonjs_bare_package_provenance() {
let cached = CachedResolveResult::from_resolve_result(
&ResolveResult::CommonJsNpmPackage("shared-package".to_string()),
&FxHashMap::default(),
)
.expect("bare CommonJS package should not need a stable file key");
let restored = cached
.into_resolve_result(&FxHashMap::default())
.expect("bare CommonJS package should restore without a file map");
assert!(matches!(
restored,
ResolveResult::CommonJsNpmPackage(package_name)
if package_name == "shared-package"
));
}
#[test]
fn cache_resolved_project_rejects_unknown_internal_targets() {
let files = vec![file(0, "/project/src/a.ts")];
let module = ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/src/a.ts"),
resolved_imports: vec![ResolvedImport {
info: import_info("./missing"),
target: ResolveResult::InternalModule(FileId(1)),
}],
..ResolvedModule::default()
};
let project = ResolvedProject {
modules: vec![module],
replaced_module_targets: Vec::new(),
..ResolvedProject::default()
};
let cached = cache_resolved_project(Path::new("/project"), &files, &project);
assert!(cached.is_none());
}
#[test]
fn cached_dynamic_pattern_targets_preserve_empty_rows() {
let files = vec![file(0, "/project/src/app.ts")];
let pattern = DynamicImportPattern {
prefix: "./missing/".into(),
suffix: Some(".ts".into()),
span: Span::new(0, 1),
mechanism: ModuleLoadMechanism::EsModule,
};
let module = ModuleInfo {
dynamic_import_patterns: vec![pattern.clone()],
..ModuleInfo::empty(FileId(0))
};
let resolved = ResolvedProject {
modules: vec![ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/src/app.ts"),
resolved_dynamic_patterns: vec![(pattern, Vec::new())],
..ResolvedModule::default()
}],
replaced_module_targets: Vec::new(),
..ResolvedProject::default()
};
let cached = cache_resolved_project(Path::new("/project"), &files, &resolved)
.expect("all dynamic pattern targets should have stable keys");
let restored = restore_resolved_project(
Path::new("/project"),
std::slice::from_ref(&module),
&files,
&cached,
)
.expect("cached dynamic pattern rows should align with extracted patterns");
assert_eq!(restored.modules[0].resolved_dynamic_patterns.len(), 1);
assert!(
restored.modules[0].resolved_dynamic_patterns[0]
.1
.is_empty()
);
let mut sparse_cached = cached;
sparse_cached.modules[0]
.resolved_dynamic_pattern_targets
.clear();
assert!(
restore_resolved_project(
Path::new("/project"),
std::slice::from_ref(&module),
&files,
&sparse_cached,
)
.is_none(),
"version-50 sparse rows must take the safe cache-miss path"
);
}
#[test]
fn cached_replaced_target_remaps_both_file_ids_by_stable_key() {
let source_key = StableFileKey::from_root_relative(
Path::new("/project"),
Path::new("/project/src/example.test.ts"),
);
let target_key = StableFileKey::from_root_relative(
Path::new("/project"),
Path::new("/project/src/dependency.ts"),
);
let key_by_file_id = FxHashMap::from_iter([
(FileId(2), source_key.clone()),
(FileId(3), target_key.clone()),
]);
let id_by_key = FxHashMap::from_iter([(source_key, FileId(8)), (target_key, FileId(9))]);
let resolved = ResolvedReplacedModuleTarget {
source_file: FileId(2),
target_file: FileId(3),
};
let cached = CachedResolvedReplacedModuleTarget::from_resolved(resolved, &key_by_file_id)
.expect("both file ids should map to stable keys");
let restored = cached
.into_resolved(&id_by_key)
.expect("both stable keys should map to current file ids");
assert_eq!(
restored,
ResolvedReplacedModuleTarget {
source_file: FileId(8),
target_file: FileId(9),
}
);
}
#[test]
fn cache_resolved_project_rejects_unknown_replacement_targets() {
let files = vec![file(0, "/project/src/example.test.ts")];
let project = ResolvedProject {
modules: vec![ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/src/example.test.ts"),
..ResolvedModule::default()
}],
replaced_module_targets: vec![ResolvedReplacedModuleTarget {
source_file: FileId(0),
target_file: FileId(1),
}],
..ResolvedProject::default()
};
let cached = cache_resolved_project(Path::new("/project"), &files, &project);
assert!(cached.is_none());
}
#[test]
fn manifest_misses_on_content_change() {
let files = vec![file(0, "/project/src/a.ts")];
let cached_map = content_hashes(&[("/project/src/a.ts", 10)]);
let current_map = content_hashes(&[("/project/src/a.ts", 11)]);
let cached = manifest(&files, mode(), &cached_map);
let current = manifest(&files, mode(), ¤t_map);
assert!(!cached.matches_inputs(¤t));
}
#[test]
fn manifest_misses_on_file_deletion() {
let before = vec![
file(0, "/project/src/a.ts"),
file(1, "/project/src/deleted.ts"),
];
let after = vec![file(0, "/project/src/a.ts")];
let map = content_hashes(&[("/project/src/a.ts", 10), ("/project/src/deleted.ts", 20)]);
let cached = manifest(&before, mode(), &map);
let current = manifest(&after, mode(), &map);
assert!(!cached.matches_inputs(¤t));
}
#[test]
fn manifest_misses_on_file_rename_with_same_content() {
let before = vec![file(0, "/project/src/old.ts")];
let after = vec![file(0, "/project/src/new.ts")];
let map = content_hashes(&[("/project/src/old.ts", 10), ("/project/src/new.ts", 10)]);
let cached = manifest(&before, mode(), &map);
let current = manifest(&after, mode(), &map);
assert!(!cached.matches_inputs(¤t));
}
#[test]
fn manifest_misses_on_workspace_scoped_file_set() {
let full_project = vec![
file(0, "/project/packages/app/src/index.ts"),
file(1, "/project/packages/shared/src/index.ts"),
];
let workspace_scoped = vec![file(0, "/project/packages/app/src/index.ts")];
let map = content_hashes(&[
("/project/packages/app/src/index.ts", 10),
("/project/packages/shared/src/index.ts", 20),
]);
let cached = manifest(&full_project, mode(), &map);
let current = manifest(&workspace_scoped, mode(), &map);
assert!(!cached.matches_inputs(¤t));
assert_eq!(
cached.classify_resolution_mismatch(¤t),
Some(CacheRejection::FileSetChanged)
);
}
#[test]
fn manifest_misses_on_mode_change() {
let files = vec![file(0, "/project/src/a.ts")];
let map = content_hashes(&[("/project/src/a.ts", 10)]);
let cached = manifest(&files, mode(), &map);
let current = manifest(&files, GraphCacheMode::new(1, 99, 3), &map);
assert!(!cached.matches_inputs(¤t));
}
#[test]
fn manifest_misses_on_version_change() {
let files = vec![file(0, "/project/src/a.ts")];
let map = content_hashes(&[("/project/src/a.ts", 10)]);
let mut cached = manifest(&files, mode(), &map);
let current = manifest(&files, mode(), &map);
cached.version = GRAPH_CACHE_VERSION + 1;
assert!(!cached.matches_inputs(¤t));
}
}