From 9a53e3c00661fd75219d89df95e06d96e570e93b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 16:51:15 +0000 Subject: [PATCH] refactor: Migrate hashing engines to task contracts Capture root package.json engines into task-contract knowledge and feed global hashing from that catalog instead of a live PackageJson read. Closes TURBO-5828. Co-authored-by: Anthony Shew --- crates/turborepo-lib/src/run/mod.rs | 8 +++-- .../src/package_graph/builder.rs | 33 +++++++++++++++++-- .../src/package_graph/mod.rs | 5 +++ .../src/task_contracts.rs | 19 ++++++++++- crates/turborepo-task-hash/src/global_hash.rs | 24 +++++++------- 5 files changed, 72 insertions(+), 17 deletions(-) diff --git a/crates/turborepo-lib/src/run/mod.rs b/crates/turborepo-lib/src/run/mod.rs index 5e77d11723486..7ebb3261fa88f 100644 --- a/crates/turborepo-lib/src/run/mod.rs +++ b/crates/turborepo-lib/src/run/mod.rs @@ -1135,8 +1135,10 @@ impl Run { .pkg_dep_graph .package_task_context(&PackageName::Root) .ok_or(Error::MissingRootWorkspace)?; + // Compatibility-payload presence is still required for JS root scopes; + // engines for hashing come from task-contract knowledge instead. let empty_root_workspace = turborepo_repository::package_graph::PackageInfo::default(); - let root_workspace = match root_context.package_info() { + let _root_workspace = match root_context.package_info() { Some(payload) => payload, None if !root_context.requires_compatibility_payload() => &empty_root_workspace, None => return Err(Error::MissingPackagePayload(PackageName::Root)), @@ -1206,8 +1208,10 @@ impl Run { .pkg_dep_graph .external_resolution_global_file_fallback() .unwrap_or_default(); + let root_engines = self.pkg_dep_graph.root_engines(); + let root_engines = (!root_engines.is_empty()).then_some(root_engines); global_file_result = Some(collect_global_file_hash_inputs( - root_workspace, + root_engines, &self.repo_root, self.pkg_dep_graph.package_manager(), &resolution_file_fallback, diff --git a/crates/turborepo-repository/src/package_graph/builder.rs b/crates/turborepo-repository/src/package_graph/builder.rs index d54c0d0389715..8ac4f3db2ac8a 100644 --- a/crates/turborepo-repository/src/package_graph/builder.rs +++ b/crates/turborepo-repository/src/package_graph/builder.rs @@ -972,8 +972,21 @@ impl<'a, T: PackageDiscovery + Send + Sync> BuildState<'a, ResolvedPackageManage crate::task_contracts::ScopeTaskContract::javascript(), )); } - crate::task_contracts::TaskContractKnowledge::build(observations) - .map_err(|error| Error::TaskContracts(error.to_string()))? + let root_engines = root_package_json + .as_ref() + .and_then(|package_json| package_json.engines()) + .map(|engines| { + engines + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + }) + .unwrap_or_default(); + crate::task_contracts::TaskContractKnowledge::build_with_engines( + observations, + root_engines, + ) + .map_err(|error| Error::TaskContracts(error.to_string()))? }); Ok(PackageGraph { @@ -1389,7 +1402,21 @@ impl BuildState<'_, ResolvedLockfile, T> { crate::task_contracts::ScopeTaskContract::javascript(), )); } - crate::task_contracts::TaskContractKnowledge::build(observations).map_err(|error| { + let root_engines = root_package_json + .as_ref() + .and_then(|package_json| package_json.engines()) + .map(|engines| { + engines + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + }) + .unwrap_or_default(); + crate::task_contracts::TaskContractKnowledge::build_with_engines( + observations, + root_engines, + ) + .map_err(|error| { discovery::Error::Failed(Box::new(Error::TaskContracts(error.to_string()))) })? }); diff --git a/crates/turborepo-repository/src/package_graph/mod.rs b/crates/turborepo-repository/src/package_graph/mod.rs index 1ac49c9d83093..45f77f85cee90 100644 --- a/crates/turborepo-repository/src/package_graph/mod.rs +++ b/crates/turborepo-repository/src/package_graph/mod.rs @@ -620,6 +620,11 @@ impl PackageGraph { self.package_manager.as_ref() } + /// Root `engines` captured into task-contract knowledge at construction. + pub fn root_engines(&self) -> &std::collections::BTreeMap { + self.task_contract_knowledge.root_engines() + } + /// Toolchains registered during graph construction. pub fn toolchains(&self) -> &crate::toolchain::ToolchainRegistry { &self.toolchains diff --git a/crates/turborepo-repository/src/task_contracts.rs b/crates/turborepo-repository/src/task_contracts.rs index a1bda5d2f2576..e08f2e12922c2 100644 --- a/crates/turborepo-repository/src/task_contracts.rs +++ b/crates/turborepo-repository/src/task_contracts.rs @@ -69,11 +69,21 @@ impl ScopeTaskContract { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TaskContractKnowledge { by_scope: BTreeMap, + /// Root `package.json` `engines` captured at observation time for global + /// hashing. Empty when there is no root JavaScript package.json. + root_engines: BTreeMap, } impl TaskContractKnowledge { pub fn build( observations: impl IntoIterator, + ) -> Result { + Self::build_with_engines(observations, BTreeMap::new()) + } + + pub fn build_with_engines( + observations: impl IntoIterator, + root_engines: BTreeMap, ) -> Result { let mut by_scope = BTreeMap::new(); for (scope, contract) in observations { @@ -81,7 +91,10 @@ impl TaskContractKnowledge { return Err(TaskContractError::DuplicateScope { scope }); } } - Ok(Self { by_scope }) + Ok(Self { + by_scope, + root_engines, + }) } pub fn for_scope(&self, scope: &str) -> ScopeTaskContract { @@ -91,6 +104,10 @@ impl TaskContractKnowledge { .unwrap_or_else(ScopeTaskContract::empty) } + pub fn root_engines(&self) -> &BTreeMap { + &self.root_engines + } + pub fn scopes(&self) -> impl Iterator { self.by_scope .iter() diff --git a/crates/turborepo-task-hash/src/global_hash.rs b/crates/turborepo-task-hash/src/global_hash.rs index d7810f0d547b6..4d65886a1b3ac 100644 --- a/crates/turborepo-task-hash/src/global_hash.rs +++ b/crates/turborepo-task-hash/src/global_hash.rs @@ -11,10 +11,7 @@ use tracing::debug; use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf, RelativeUnixPathBuf}; use turborepo_env::{DetailedMap, EnvironmentVariableMap, get_global_hashable_env_vars}; use turborepo_hash::{GlobalHashable, TurboHash}; -use turborepo_repository::{ - package_graph::PackageInfo, - package_manager::{self, PackageManager}, -}; +use turborepo_repository::package_manager::{self, PackageManager}; use turborepo_run_summary::{GlobalEnvVarSummary, GlobalHashSummary}; use turborepo_scm::SCM; use turborepo_types::{EnvMode, GlobalHashInputs as GlobalHashInputsTrait}; @@ -64,7 +61,7 @@ pub struct GlobalHashableInputs<'a> { pub fn get_global_hash_inputs<'a>( root_external_dependencies_hash: Option<&'a str>, root_internal_dependencies_hash: Option<&'a str>, - root_package: &'a PackageInfo, + root_engines: Option<&'a std::collections::BTreeMap>, root_path: &AbsoluteSystemPath, package_manager: Option<&PackageManager>, resolution_file_fallback: &[AbsoluteSystemPathBuf], @@ -82,7 +79,7 @@ pub fn get_global_hash_inputs<'a>( global_hashable_env_vars, engines, } = collect_global_file_hash_inputs( - root_package, + root_engines, root_path, package_manager, resolution_file_fallback, @@ -129,7 +126,8 @@ pub struct GlobalFileHashInputs<'a> { /// hashing since it has no dependencies on those results. #[allow(clippy::too_many_arguments, clippy::result_large_err)] pub fn collect_global_file_hash_inputs<'a>( - root_package: &'a PackageInfo, + // Root `engines` from task-contract knowledge (not a live PackageJson read). + root_engines: Option<&'a std::collections::BTreeMap>, root_path: &AbsoluteSystemPath, // Absent for a pure Cargo workspace: there is no JavaScript package // manager to enumerate workspace exclusions for `globalDependencies`. @@ -143,7 +141,12 @@ pub fn collect_global_file_hash_inputs<'a>( global_env: &'a [String], hasher: &SCM, ) -> Result, Error> { - let engines = root_package.package_json.engines(); + let engines = root_engines.map(|engines| { + engines + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect() + }); let global_hashable_env_vars = get_global_hashable_env_vars(env_at_execution_start, global_env)?; @@ -386,7 +389,7 @@ impl<'a> GlobalHashInputsTrait for GlobalHashableInputs<'a> { mod tests { use turbopath::AbsoluteSystemPathBuf; use turborepo_env::EnvironmentVariableMap; - use turborepo_repository::{package_graph::PackageInfo, package_manager::PackageManager}; + use turborepo_repository::package_manager::PackageManager; use turborepo_scm::SCM; use turborepo_types::EnvMode; @@ -408,7 +411,6 @@ mod tests { .unwrap(); let env_var_map = EnvironmentVariableMap::default(); - let package_info = PackageInfo::default(); let fallback = [root.join_component("package.json")]; #[cfg(windows)] let file_deps = ["C:\\some\\path".to_string()]; @@ -417,7 +419,7 @@ mod tests { let result = get_global_hash_inputs( None, None, - &package_info, + None, &root, Some(&PackageManager::Pnpm), &fallback,