From 6e9957ed495348a48b75ef0e2ffbbb0575765cdd Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 28 Jul 2026 12:18:25 -0700 Subject: [PATCH 1/2] refactor: Add core relationship projections --- Cargo.lock | 2 + .../src/package_graph/builder.rs | 61 ++ .../src/package_graph/mod.rs | 123 ++- .../src/package_graph/projections.rs | 790 ++++++++++++++++++ crates/turborepo/ARCHITECTURE.md | 15 +- crates/turborepo/Cargo.toml | 2 + .../tests/relationship_projection_contract.rs | 145 ++++ 7 files changed, 1132 insertions(+), 6 deletions(-) create mode 100644 crates/turborepo-repository/src/package_graph/projections.rs diff --git a/Cargo.lock b/Cargo.lock index e81e23eab96bf..866a0ae449d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8136,6 +8136,8 @@ dependencies = [ "serde_json", "tempfile", "terminal-control", + "tokio", + "turbopath", "turborepo-lib", "turborepo-lsp", "turborepo-query", diff --git a/crates/turborepo-repository/src/package_graph/builder.rs b/crates/turborepo-repository/src/package_graph/builder.rs index 456b4f5a6a601..3640b83eb826a 100644 --- a/crates/turborepo-repository/src/package_graph/builder.rs +++ b/crates/turborepo-repository/src/package_graph/builder.rs @@ -918,6 +918,7 @@ impl<'a, T: PackageDiscovery + Send + Sync> BuildState<'a, ResolvedPackageManage package_manager, knowledge, relationship_knowledge, + relationship_projections: std::sync::OnceLock::new(), deferred_closures: std::sync::Mutex::new(None), external_dep_to_internal_dependents: std::sync::OnceLock::new(), root_internal_dependencies: std::sync::OnceLock::new(), @@ -1320,6 +1321,7 @@ impl BuildState<'_, ResolvedLockfile, T> { lockfile: arc_lockfile, knowledge, relationship_knowledge, + relationship_projections: std::sync::OnceLock::new(), deferred_closures: std::sync::Mutex::new(deferred_closures), external_dep_to_internal_dependents: std::sync::OnceLock::new(), root_internal_dependencies: std::sync::OnceLock::new(), @@ -1998,6 +2000,65 @@ mod test { "web should depend on ui, got: {:?}", web_deps ); + let mut graph_ordering: Vec<_> = web_deps + .iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) => Some(name.clone()), + PackageNode::Root => None, + }) + .collect(); + graph_ordering.sort(); + let projected_ordering: Vec<_> = graph + .ordering_relationships() + .direct_dependencies(&web_name) + .expect("web is authoritative") + .cloned() + .collect(); + assert_eq!(projected_ordering, graph_ordering); + + let mut graph_dependencies: Vec<_> = graph + .dependencies(&PackageNode::Workspace(web_name.clone())) + .into_iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) if name != &web_name => Some(name.clone()), + PackageNode::Root | PackageNode::Workspace(_) => None, + }) + .collect(); + graph_dependencies.sort(); + assert_eq!( + graph + .filtering_relationships() + .transitive_dependencies(&web_name), + Some(graph_dependencies.clone()) + ); + assert_eq!( + graph.hash_relationships().dependency_inputs(&web_name), + Some(graph_dependencies) + ); + + let mut graph_dependents: Vec<_> = graph + .ancestors(&PackageNode::Workspace(ui_name.clone())) + .into_iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) if name != &ui_name => Some(name.clone()), + PackageNode::Root | PackageNode::Workspace(_) => None, + }) + .collect(); + graph_dependents.sort(); + assert_eq!( + graph + .filtering_relationships() + .transitive_dependents(&ui_name), + Some(graph_dependents.clone()) + ); + graph_dependents.push(ui_name.clone()); + graph_dependents.sort(); + assert_eq!( + graph + .affected_relationships() + .affected_by(std::slice::from_ref(&ui_name)), + Ok(graph_dependents) + ); // api -> utils (internal) let api_deps = graph diff --git a/crates/turborepo-repository/src/package_graph/mod.rs b/crates/turborepo-repository/src/package_graph/mod.rs index cbe6521d3ddb8..85058ecda69ea 100644 --- a/crates/turborepo-repository/src/package_graph/mod.rs +++ b/crates/turborepo-repository/src/package_graph/mod.rs @@ -23,8 +23,13 @@ use crate::{ pub mod builder; mod dep_splitter; +mod projections; pub use builder::{Error, PackageGraphBuilder}; +pub use projections::{ + AffectedRelationships, FilteringRelationships, HashRelationships, OrderingRelationships, + PruneDependencyMode, PruneRelationships, RelationshipProjectionError, +}; pub use crate::package_json::DependencyKind; @@ -58,6 +63,7 @@ pub struct PackageGraph { /// unresolved external declaration maps. #[allow(dead_code)] relationship_knowledge: Arc, + relationship_projections: OnceLock, /// Receiver for background transitive-closure computation when the graph /// was built with deferred closures. Consumed (exactly once) by /// [`Self::ensure_transitive_closures`]. @@ -281,6 +287,47 @@ pub struct ExternalDependencyChange { } impl PackageGraph { + fn relationship_projections(&self) -> &projections::RelationshipProjections { + self.relationship_projections.get_or_init(|| { + projections::RelationshipProjections::build( + &self.knowledge, + &self.relationship_knowledge, + ) + }) + } + + /// Returns direct graph-forming relationships for task ordering. + /// + /// The view shares this graph generation's immutable relationship index and + /// never exposes the structural [`PackageNode::Root`] sentinel. + pub fn ordering_relationships(&self) -> &OrderingRelationships { + self.relationship_projections().ordering() + } + + /// Returns transitive relationships with root-implied filtering semantics. + pub fn filtering_relationships(&self) -> &FilteringRelationships { + self.relationship_projections().filtering() + } + + /// Returns reverse ordering relationships for affectedness propagation. + pub fn affected_relationships(&self) -> &AffectedRelationships { + self.relationship_projections().affected() + } + + /// Returns full transitive internal dependency inputs for hashing. + pub fn hash_relationships(&self) -> &HashRelationships { + self.relationship_projections().hash() + } + + /// Returns install-oriented package relationships for pruning. + /// + /// Required peers whose declaration names match authoritative workspaces + /// are included independently of classification and graph declaration + /// precedence; optional peers are excluded. + pub fn prune_relationships(&self) -> &PruneRelationships { + self.relationship_projections().prune() + } + pub fn builder( repo_root: &AbsoluteSystemPath, root_package_json: PackageJson, @@ -2274,9 +2321,15 @@ mod test { write(&["rust", "app", "src", "main.rs"], "fn main() {}\n"); write( &["rust", "lib-a", "Cargo.toml"], - "[package]\nname = \"lib-a\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + "[package]\nname = \"lib-a\"\nversion = \"0.1.0\"\nedition = \ + \"2021\"\n\n[dependencies]\nlib-b = { path = \"../lib-b\" }\n", ); write(&["rust", "lib-a", "src", "lib.rs"], ""); + write( + &["rust", "lib-b", "Cargo.toml"], + "[package]\nname = \"lib-b\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ); + write(&["rust", "lib-b", "src", "lib.rs"], ""); write( &["Cargo.lock"], r#"version = 4 @@ -2289,6 +2342,11 @@ dependencies = ["lib-a"] [[package]] name = "lib-a" version = "0.1.0" +dependencies = ["lib-b"] + +[[package]] +name = "lib-b" +version = "0.1.0" "#, ); } @@ -2445,6 +2503,20 @@ version = "0.1.0" app_deps.contains(&PackageNode::Workspace(PackageName::from("lib-a"))), "app should depend on lib-a, got {app_deps:?}" ); + assert_eq!( + pkg_graph + .ordering_relationships() + .direct_dependencies(&PackageName::from("app")) + .map(|dependencies| dependencies.cloned().collect::>()), + Some(vec![PackageName::from("lib-a")]) + ); + assert_eq!( + pkg_graph + .hash_relationships() + .dependency_inputs(&PackageName::from("app")), + Some(vec![PackageName::from("lib-a"), PackageName::from("lib-b")]), + "mixed repositories retain transitive native hash inputs" + ); let workspace_deps = pkg_graph .immediate_dependencies(&PackageNode::Workspace(PackageName::from("acme"))) .unwrap(); @@ -2500,6 +2572,55 @@ version = "0.1.0" .unwrap(); assert!(pkg_graph.validate().is_ok()); + assert!( + pkg_graph.relationship_projections.get().is_none(), + "relationship projections must remain lazy for current consumers" + ); + + assert_eq!( + pkg_graph + .ordering_relationships() + .direct_dependencies(&PackageName::Root) + .map(|dependencies| dependencies.cloned().collect::>()), + Some(Vec::new()), + "pure Cargo recognizes the root Turbo namespace without JS edges" + ); + assert!(pkg_graph.relationship_projections.get().is_some()); + let app = PackageName::from("app"); + let expected_dependencies = vec![PackageName::from("lib-a"), PackageName::from("lib-b")]; + assert_eq!( + pkg_graph.hash_relationships().dependency_inputs(&app), + Some(expected_dependencies.clone()), + "Cargo hash inputs include transitive path dependencies" + ); + assert_eq!( + pkg_graph + .filtering_relationships() + .transitive_dependencies(&app), + Some(expected_dependencies.clone()) + ); + let mut graph_dependencies: Vec<_> = pkg_graph + .dependencies(&PackageNode::Workspace(app.clone())) + .into_iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) if name != &app => Some(name.clone()), + PackageNode::Root | PackageNode::Workspace(_) => None, + }) + .collect(); + graph_dependencies.sort(); + assert_eq!(graph_dependencies, expected_dependencies); + assert_eq!( + pkg_graph.prune_relationships().package_closure( + std::slice::from_ref(&app), + PruneDependencyMode::ProductionOnly, + ), + Ok(vec![ + PackageName::Root, + PackageName::from("app"), + PackageName::from("lib-a"), + PackageName::from("lib-b"), + ]) + ); assert!( pkg_graph diff --git a/crates/turborepo-repository/src/package_graph/projections.rs b/crates/turborepo-repository/src/package_graph/projections.rs new file mode 100644 index 0000000000000..0a4358aa05e91 --- /dev/null +++ b/crates/turborepo-repository/src/package_graph/projections.rs @@ -0,0 +1,790 @@ +//! Typed, consumer-oriented projections of normalized package relationships. + +use std::{collections::HashMap, sync::Arc}; + +use super::PackageName; +use crate::{ + knowledge::{RelationshipKnowledge, RepositoryKnowledge}, + relationships::{DependencyKind, RelationshipTarget}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct ScopeId(usize); + +impl ScopeId { + fn index(self) -> usize { + self.0 + } +} + +type Adjacency = Box<[Box<[ScopeId]>]>; + +/// Controls whether package pruning follows development dependencies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PruneDependencyMode { + /// Follow production, development, and required internal peer + /// relationships. + IncludeDevDependencies, + /// Follow production and required internal peer relationships only. + ProductionOnly, +} + +/// An invalid package identity supplied to a batch relationship query. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RelationshipProjectionError { + /// The package is not an authoritative package scope or the always-present + /// root Turbo task namespace. + #[error("unknown package {0}")] + UnknownPackage(PackageName), +} + +#[derive(Debug)] +struct RelationshipIndex { + names: Box<[PackageName]>, + non_root_lookup: HashMap, ScopeId>, + ordering: Adjacency, + reverse_ordering: Adjacency, + prune_all: Adjacency, + prune_production: Adjacency, + root: ScopeId, + root_inputs: Box<[ScopeId]>, + is_root_input: Box<[bool]>, +} + +impl RelationshipIndex { + fn build(repository: &RepositoryKnowledge, relationships: &RelationshipKnowledge) -> Self { + // Root is the always-present Turbo task namespace, not PackageNode::Root. + let mut names = vec![PackageName::Root]; + names.extend( + repository + .scopes() + .map(|scope| PackageName::Other(scope.identity().to_string())), + ); + names[1..].sort(); + let names = names.into_boxed_slice(); + let non_root_lookup: HashMap<_, _> = names + .iter() + .enumerate() + .filter_map(|(index, name)| match name { + PackageName::Root => None, + PackageName::Other(name) => Some((name.clone().into_boxed_str(), ScopeId(index))), + }) + .collect(); + let root = ScopeId(0); + let mut ordering = vec![Vec::new(); names.len()]; + let mut prune_all = vec![Vec::new(); names.len()]; + let mut prune_production = vec![Vec::new(); names.len()]; + + for group in relationships.groups() { + let Some(source) = lookup_identity(&non_root_lookup, root, group.source()) else { + unreachable!("validated relationship source must have a scope ID") + }; + let mut seen_declarations = std::collections::HashSet::new(); + let mut seen_targets = std::collections::HashSet::new(); + let mut effective_concrete = Vec::new(); + let mut required_peers = Vec::new(); + + for relationship in group.relationships() { + // Existing prune semantics intentionally use the required peer's + // declaration name, independently of classification and ordinary + // declaration precedence. This retains an authoritative same-name + // workspace even when the peer specifier was classified external + // or a duplicate dev declaration won the graph projection. + // Optional peers are never retained here. + if relationship.kind() == (DependencyKind::Peer { optional: false }) + && let Some(target) = + lookup_identity(&non_root_lookup, root, relationship.declaration_name()) + { + required_peers.push(target); + } + + if !seen_declarations.insert(relationship.declaration_name()) { + continue; + } + let kind = relationship.kind(); + let RelationshipTarget::Internal(target) = relationship.target() else { + continue; + }; + if !matches!( + kind, + DependencyKind::Production | DependencyKind::Development + ) { + continue; + } + let Some(target) = lookup_identity(&non_root_lookup, root, target) else { + unreachable!("validated internal relationship target must have a scope ID") + }; + if seen_targets.insert(target) { + effective_concrete.push((target, kind)); + } + } + + ordering[source.index()].extend(effective_concrete.iter().map(|(target, _)| *target)); + prune_all[source.index()].extend(effective_concrete.iter().map(|(target, _)| *target)); + prune_all[source.index()].extend(required_peers.iter().copied()); + prune_production[source.index()].extend( + effective_concrete + .iter() + .filter(|(_, kind)| *kind == DependencyKind::Production) + .map(|(target, _)| *target), + ); + prune_production[source.index()].extend(required_peers); + } + + let ordering = normalize_adjacency(ordering); + let prune_all = normalize_adjacency(prune_all); + let prune_production = normalize_adjacency(prune_production); + let reverse_ordering = reverse_adjacency(names.len(), &ordering); + let mut root_members = closure_members(&ordering, &[root]); + root_members[root.index()] = false; + let root_inputs = member_ids(&root_members); + + Self { + names, + non_root_lookup, + ordering, + reverse_ordering, + prune_all, + prune_production, + root, + root_inputs, + is_root_input: root_members.into_boxed_slice(), + } + } + + fn id(&self, package: &PackageName) -> Option { + match package { + PackageName::Root => Some(self.root), + PackageName::Other(name) => self.non_root_lookup.get(name.as_str()).copied(), + } + } + + fn ids(&self, packages: &[PackageName]) -> Result, RelationshipProjectionError> { + packages + .iter() + .map(|package| { + self.id(package) + .ok_or_else(|| RelationshipProjectionError::UnknownPackage(package.clone())) + }) + .collect() + } + + fn transitive_dependencies(&self, package: &PackageName) -> Option> { + let package = self.id(package)?; + let mut members = closure_members(&self.ordering, &[package]); + include_ids(&mut members, &self.root_inputs); + members[package.index()] = false; + Some(self.names_from_members(&members)) + } + + fn transitive_dependents(&self, package: &PackageName) -> Option> { + let package = self.id(package)?; + let mut members = if self.is_root_input[package.index()] { + vec![true; self.names.len()] + } else { + closure_members(&self.reverse_ordering, &[package]) + }; + members[package.index()] = false; + Some(self.names_from_members(&members)) + } + + fn names_from_members(&self, members: &[bool]) -> Vec { + members + .iter() + .enumerate() + .filter(|(_, included)| **included) + .map(|(index, _)| self.names[index].clone()) + .collect() + } + + fn names_from_ids(&self, ids: &[ScopeId]) -> Vec { + ids.iter() + .map(|id| self.names[id.index()].clone()) + .collect() + } +} + +fn lookup_identity( + non_root_lookup: &HashMap, ScopeId>, + root: ScopeId, + identity: &str, +) -> Option { + if identity == super::ROOT_PKG_NAME { + Some(root) + } else { + non_root_lookup.get(identity).copied() + } +} + +fn normalize_adjacency(mut adjacency: Vec>) -> Adjacency { + adjacency.iter_mut().for_each(|targets| { + targets.sort_unstable_by_key(|target| target.index()); + targets.dedup(); + }); + adjacency + .into_iter() + .map(Vec::into_boxed_slice) + .collect::>() + .into_boxed_slice() +} + +fn reverse_adjacency(scope_count: usize, adjacency: &Adjacency) -> Adjacency { + let mut reverse = vec![Vec::new(); scope_count]; + for (source, targets) in adjacency.iter().enumerate() { + for target in targets { + reverse[target.index()].push(ScopeId(source)); + } + } + normalize_adjacency(reverse) +} + +fn closure_members(adjacency: &Adjacency, seeds: &[ScopeId]) -> Vec { + let mut visited = vec![false; adjacency.len()]; + let mut stack = Vec::with_capacity(seeds.len()); + for seed in seeds { + if !visited[seed.index()] { + visited[seed.index()] = true; + stack.push(*seed); + } + } + while let Some(current) = stack.pop() { + for target in &adjacency[current.index()] { + if !visited[target.index()] { + // Mark on enqueue so duplicate paths cannot grow the stack. + visited[target.index()] = true; + stack.push(*target); + } + } + } + visited +} + +fn include_ids(members: &mut [bool], ids: &[ScopeId]) { + for id in ids { + members[id.index()] = true; + } +} + +fn member_ids(members: &[bool]) -> Box<[ScopeId]> { + members + .iter() + .enumerate() + .filter(|(_, included)| **included) + .map(|(index, _)| ScopeId(index)) + .collect::>() + .into_boxed_slice() +} + +/// Direct graph-forming package relationships used for task ordering. +/// +/// Production and development declarations participate with first-declaration +/// and first-target precedence. Peer relationships, unresolved externals, and +/// the package graph's structural root sentinel are excluded. Results are +/// sorted and deduplicated. +#[derive(Debug, Clone)] +pub struct OrderingRelationships(Arc); + +impl OrderingRelationships { + /// Returns direct internal dependencies, or `None` for an unknown package. + /// + /// The root Turbo namespace is always recognized. In a pure native + /// repository it has no declared dependencies. The opaque iterator borrows + /// the index and has an exact size; items are sorted and deduplicated. + pub fn direct_dependencies( + &self, + package: &PackageName, + ) -> Option + DoubleEndedIterator + Clone + '_> + { + let id = self.0.id(package)?; + Some( + self.0.ordering[id.index()] + .iter() + .map(|dependency| &self.0.names[dependency.index()]), + ) + } +} + +/// Transitive package relationships used by package filtering. +/// +/// Dependency queries include the root JavaScript package's transitive internal +/// dependencies as implied dependencies. Dependent queries preserve the +/// corresponding all-packages behavior for those root inputs. +#[derive(Debug, Clone)] +pub struct FilteringRelationships(Arc); + +impl FilteringRelationships { + /// Returns sorted transitive dependencies excluding `package`, or `None` + /// when the package is unknown. + pub fn transitive_dependencies(&self, package: &PackageName) -> Option> { + self.0.transitive_dependencies(package) + } + + /// Returns sorted transitive dependents excluding `package`, or `None` when + /// the package is unknown. A root internal input has every other + /// authoritative identity as a dependent. + pub fn transitive_dependents(&self, package: &PackageName) -> Option> { + self.0.transitive_dependents(package) + } + + /// Returns one sorted, deduplicated dependency closure including all seeds. + /// + /// The traversal is performed once for the complete seed set. Empty input + /// returns an empty vector. If any seed is unknown, no partial result is + /// returned. + pub fn dependency_closure( + &self, + packages: &[PackageName], + ) -> Result, RelationshipProjectionError> { + let seeds = self.0.ids(packages)?; + if seeds.is_empty() { + return Ok(Vec::new()); + } + let mut members = closure_members(&self.0.ordering, &seeds); + include_ids(&mut members, &self.0.root_inputs); + Ok(self.0.names_from_members(&members)) + } +} + +/// Reverse ordering relationships used to determine affected packages. +#[derive(Debug, Clone)] +pub struct AffectedRelationships(Arc); + +impl AffectedRelationships { + /// Returns changed seeds and all transitive ordering dependents. + /// + /// If any seed is a root internal input, every authoritative identity, + /// including the root Turbo namespace, is affected. Empty input is valid. + /// If any seed is unknown, no partial result is returned. Output is sorted + /// and deduplicated. + pub fn affected_by( + &self, + packages: &[PackageName], + ) -> Result, RelationshipProjectionError> { + let seeds = self.0.ids(packages)?; + if seeds.iter().any(|seed| self.0.is_root_input[seed.index()]) { + return Ok(self.0.names.to_vec()); + } + let members = closure_members(&self.0.reverse_ordering, &seeds); + Ok(self.0.names_from_members(&members)) + } +} + +/// Internal dependency inputs used by derived task hashing and I/O. +/// +/// External lockfile closures are deliberately outside this projection. +#[derive(Debug, Clone)] +pub struct HashRelationships(Arc); + +impl HashRelationships { + /// Returns the full transitive internal dependency inputs, including + /// implied root inputs and excluding `package`, or `None` if unknown. + /// Output is sorted and deduplicated. + pub fn dependency_inputs(&self, package: &PackageName) -> Option> { + self.0.transitive_dependencies(package) + } + + /// Returns the sorted, deduplicated transitive internal inputs declared by + /// the root JavaScript package. Pure native repositories return an empty + /// vector while still recognizing [`PackageName::Root`]. + pub fn root_inputs(&self) -> Vec { + self.0.names_from_ids(&self.0.root_inputs) + } +} + +/// Install-oriented package relationships used by pruning. +/// +/// Unlike ordering, pruning independently resolves a required peer's +/// declaration name to an authoritative same-name workspace, regardless of +/// relationship classification or ordinary graph precedence. This mirrors +/// existing prune behavior for incompatible peer specifiers and aliases. +/// Optional peers are excluded from this package-only closure. +#[derive(Debug, Clone)] +pub struct PruneRelationships(Arc); + +impl PruneRelationships { + /// Returns a sorted, deduplicated package closure for `mode`. + /// + /// The root Turbo namespace is always an implicit seed, including in pure + /// native repositories, and its mode-appropriate dependency closure is + /// included. Empty input therefore returns at least the root namespace. The + /// complete seed set is traversed once. If any explicit seed is unknown, no + /// partial result is returned. + pub fn package_closure( + &self, + packages: &[PackageName], + mode: PruneDependencyMode, + ) -> Result, RelationshipProjectionError> { + let mut seeds = self.0.ids(packages)?; + seeds.push(self.0.root); + let adjacency = match mode { + PruneDependencyMode::IncludeDevDependencies => &self.0.prune_all, + PruneDependencyMode::ProductionOnly => &self.0.prune_production, + }; + let members = closure_members(adjacency, &seeds); + Ok(self.0.names_from_members(&members)) + } +} + +#[derive(Debug)] +pub(super) struct RelationshipProjections { + ordering: OrderingRelationships, + filtering: FilteringRelationships, + affected: AffectedRelationships, + hash: HashRelationships, + prune: PruneRelationships, +} + +impl RelationshipProjections { + pub(super) fn build( + repository: &RepositoryKnowledge, + relationships: &RelationshipKnowledge, + ) -> Self { + let index = Arc::new(RelationshipIndex::build(repository, relationships)); + Self { + ordering: OrderingRelationships(Arc::clone(&index)), + filtering: FilteringRelationships(Arc::clone(&index)), + affected: AffectedRelationships(Arc::clone(&index)), + hash: HashRelationships(Arc::clone(&index)), + prune: PruneRelationships(index), + } + } + + pub(super) fn ordering(&self) -> &OrderingRelationships { + &self.ordering + } + + pub(super) fn filtering(&self) -> &FilteringRelationships { + &self.filtering + } + + pub(super) fn affected(&self) -> &AffectedRelationships { + &self.affected + } + + pub(super) fn hash(&self) -> &HashRelationships { + &self.hash + } + + pub(super) fn prune(&self) -> &PruneRelationships { + &self.prune + } +} + +#[cfg(test)] +mod tests { + use turbopath::AbsoluteSystemPathBuf; + + use super::*; + use crate::{ + knowledge::{ + PackageScopeObservation, RelationshipGroup, ScopeKind, WorkspaceRootObservation, + }, + relationships::Relationship, + toolchain::{ToolchainId, WorkspaceRoot}, + }; + + fn name(value: &str) -> PackageName { + PackageName::Other(value.to_string()) + } + + fn names(values: &[&str]) -> Vec { + values.iter().map(|value| name(value)).collect() + } + + fn with_root(mut values: Vec) -> Vec { + values.push(PackageName::Root); + values.sort(); + values + } + + fn fixture(with_javascript_root: bool) -> RelationshipProjections { + let root = AbsoluteSystemPathBuf::new(if cfg!(windows) { r"C:\repo" } else { "/repo" }) + .expect("test root is absolute"); + let identities = [ + "app", + "dev", + "optional", + "prod", + "required-peer", + "optional-peer", + "transitive", + "root-lib", + "cycle-a", + "cycle-b", + "disconnected", + ]; + let toolchain = if with_javascript_root { + ToolchainId::JAVASCRIPT + } else { + ToolchainId::RUST + }; + let observations: Vec<_> = identities + .iter() + .map(|identity| PackageScopeObservation { + identity: Some((*identity).to_string()), + definition_path: root.join_components(&[identity, "package.json"]), + toolchain: toolchain.clone(), + scope_kind: ScopeKind::Package, + }) + .collect(); + let repository = RepositoryKnowledge::build( + &root, + with_javascript_root.then_some(Some("root".to_string())), + &observations, + &[WorkspaceRootObservation::new( + WorkspaceRoot::new( + if with_javascript_root { "npm" } else { "cargo" }, + root.clone(), + ), + toolchain, + )], + ) + .expect("fixture repository is valid"); + let mut groups = vec![ + RelationshipGroup::new( + "app", + vec![ + Relationship::internal("prod", DependencyKind::Production), + Relationship::internal("dev", DependencyKind::Development), + Relationship::internal("optional", DependencyKind::Production), + Relationship::internal("required-peer", DependencyKind::Development), + Relationship::internal( + "optional-peer", + DependencyKind::Peer { optional: true }, + ), + // The same-name dev declaration wins graph precedence, but + // production prune still retains the authoritative workspace + // by the required peer's declaration name. Its incompatible + // specifier/classification is intentionally irrelevant. + Relationship::new( + "required-peer", + DependencyKind::Peer { optional: false }, + RelationshipTarget::UnresolvedExternal { + name: "required-peer".to_string(), + specifier: "^999.0.0".to_string(), + }, + ), + Relationship::new( + "prod-alias", + DependencyKind::Development, + RelationshipTarget::Internal("prod".to_string()), + ), + Relationship::new( + "dev-prod-alias", + DependencyKind::Production, + RelationshipTarget::Internal("dev".to_string()), + ), + Relationship::new( + "external", + DependencyKind::Production, + RelationshipTarget::UnresolvedExternal { + name: "external".to_string(), + specifier: "1.0.0".to_string(), + }, + ), + ], + ), + RelationshipGroup::new( + "prod", + vec![Relationship::internal( + "transitive", + DependencyKind::Production, + )], + ), + RelationshipGroup::new( + "cycle-a", + vec![ + Relationship::internal("cycle-a", DependencyKind::Production), + Relationship::internal("cycle-b", DependencyKind::Production), + ], + ), + RelationshipGroup::new( + "cycle-b", + vec![Relationship::internal( + "cycle-a", + DependencyKind::Production, + )], + ), + ]; + if with_javascript_root { + groups.push(RelationshipGroup::new( + "//", + vec![Relationship::internal( + "root-lib", + DependencyKind::Production, + )], + )); + } + let relationships = + RelationshipKnowledge::build(&repository, groups).expect("relationships are valid"); + RelationshipProjections::build(&repository, &relationships) + } + + #[test] + fn projections_match_order_filter_affected_hash_and_prune_semantics() { + let projections = fixture(true); + let app = name("app"); + + assert_eq!( + projections + .ordering() + .direct_dependencies(&app) + .map(|dependencies| dependencies.cloned().collect::>()), + Some(names(&["dev", "optional", "prod", "required-peer"])) + ); + assert_eq!( + projections + .ordering() + .direct_dependencies(&name("disconnected")) + .map(|dependencies| dependencies.cloned().collect::>()), + Some(Vec::new()) + ); + let transitive = names(&[ + "dev", + "optional", + "prod", + "required-peer", + "root-lib", + "transitive", + ]); + assert_eq!( + projections.filtering().transitive_dependencies(&app), + Some(transitive.clone()) + ); + assert_eq!(projections.hash().dependency_inputs(&app), Some(transitive)); + assert_eq!(projections.hash().root_inputs(), names(&["root-lib"])); + assert_eq!( + projections + .filtering() + .dependency_closure(std::slice::from_ref(&app)), + Ok(names(&[ + "app", + "dev", + "optional", + "prod", + "required-peer", + "root-lib", + "transitive" + ])) + ); + assert_eq!( + projections.affected().affected_by(&[name("transitive")]), + Ok(names(&["app", "prod", "transitive"])) + ); + assert_eq!( + projections.prune().package_closure( + std::slice::from_ref(&app), + PruneDependencyMode::IncludeDevDependencies, + ), + Ok(with_root(names(&[ + "app", + "dev", + "optional", + "prod", + "required-peer", + "root-lib", + "transitive", + ]))) + ); + assert_eq!( + projections + .prune() + .package_closure(&[app], PruneDependencyMode::ProductionOnly,), + Ok(with_root(names(&[ + "app", + "optional", + "prod", + "required-peer", + "root-lib", + "transitive", + ]))) + ); + } + + #[test] + fn root_implication_cycles_self_edges_and_unknown_batches_are_exact() { + let projections = fixture(true); + let root_lib = name("root-lib"); + let cycle_a = name("cycle-a"); + let all_but_root_lib = projections + .filtering() + .transitive_dependents(&root_lib) + .expect("root-lib is authoritative"); + assert_eq!(all_but_root_lib.len(), 11); + assert!(!all_but_root_lib.contains(&root_lib)); + assert_eq!( + projections.filtering().transitive_dependencies(&cycle_a), + Some(names(&["cycle-b", "root-lib"])) + ); + assert_eq!( + projections.prune().package_closure( + std::slice::from_ref(&cycle_a), + PruneDependencyMode::IncludeDevDependencies, + ), + Ok(with_root(names(&["cycle-a", "cycle-b", "root-lib"]))) + ); + assert_eq!( + projections.affected().affected_by(&[root_lib]), + Ok(with_root(names(&[ + "app", + "cycle-a", + "cycle-b", + "dev", + "disconnected", + "optional", + "optional-peer", + "prod", + "required-peer", + "root-lib", + "transitive", + ]))) + ); + let unknown = name("missing"); + assert_eq!( + projections + .filtering() + .dependency_closure(&[cycle_a.clone(), unknown.clone()]), + Err(RelationshipProjectionError::UnknownPackage(unknown.clone())) + ); + assert_eq!( + projections + .affected() + .affected_by(&[cycle_a.clone(), unknown.clone()]), + Err(RelationshipProjectionError::UnknownPackage(unknown.clone())) + ); + assert_eq!( + projections.prune().package_closure( + &[cycle_a, unknown.clone()], + PruneDependencyMode::IncludeDevDependencies, + ), + Err(RelationshipProjectionError::UnknownPackage(unknown)) + ); + } + + #[test] + fn pure_native_generation_keeps_root_namespace_without_declared_edges() { + let projections = fixture(false); + assert_eq!( + projections + .ordering() + .direct_dependencies(&PackageName::Root) + .map(|dependencies| dependencies.cloned().collect::>()), + Some(Vec::new()) + ); + assert!(projections.hash().root_inputs().is_empty()); + assert_eq!( + projections + .prune() + .package_closure(&[], PruneDependencyMode::IncludeDevDependencies,), + Ok(vec![PackageName::Root]) + ); + assert_eq!( + projections.affected().affected_by(&[name("disconnected")]), + Ok(names(&["disconnected"])) + ); + assert_eq!( + projections.filtering().dependency_closure(&[]), + Ok(Vec::new()) + ); + } +} diff --git a/crates/turborepo/ARCHITECTURE.md b/crates/turborepo/ARCHITECTURE.md index 82829e452b8bf..0479848647b5d 100644 --- a/crates/turborepo/ARCHITECTURE.md +++ b/crates/turborepo/ARCHITECTURE.md @@ -144,8 +144,13 @@ Represents the workspace structure and package dependencies: without native facts (JavaScript and legacy custom toolchains) are classified once with package-manager policy; Cargo supplies already-classified native internal relationships. Cargo.lock external closures remain separate. Package - graph edges and unresolved external declaration maps are projections of the - normalized generation. + graph edges, unresolved external declaration maps, and the typed ordering, + filtering, affectedness, hashing, and pruning relationship views are + projections of the normalized generation. The typed views lazily initialize + and share one immutable, compact scope-ID relationship index, so current + consumers pay no construction cost. They expose only authoritative package + identities plus the always-present root Turbo task namespace, never the + package graph's structural root sentinel. - Performs ecosystem-specific lockfile analysis - Builds dependency relationships between workspace packages - Validates that all non-root packages have a `name` field @@ -285,9 +290,9 @@ whether anything changed; Cargo decides how and in what order to build.** dependency tables, renames). Dev-dependency edges that would form a cycle are dropped (Cargo permits dev-dep cycles; crate edges must support topological `^` ordering). Crate names are validated, and a crate/JS package - name collision hard-errors. Crate path dependencies are synthesized as - `workspace:*` specifiers in the toolchain-neutral descriptor, so the existing - dependency splitter wires crate→crate edges. A second full `cargo metadata + name collision hard-errors. Cargo contributes its already-classified native + internal relationships directly, without JavaScript dependency descriptors + or package-manager policy. A second full `cargo metadata --locked --all-features` pass validates resolution and every resolved local package: automatic in-repository workspace members are supported, while excluded/non-member, outside-repository, and root-manifest local packages diff --git a/crates/turborepo/Cargo.toml b/crates/turborepo/Cargo.toml index a026873b86a24..e4e2b4528f51e 100644 --- a/crates/turborepo/Cargo.toml +++ b/crates/turborepo/Cargo.toml @@ -28,6 +28,8 @@ nix = { workspace = true, features = ["signal"] } portable-pty = "0.9.0" pretty_assertions = { workspace = true } serde_json = { workspace = true } +tokio = { workspace = true } +turbopath = { workspace = true } tempfile = { workspace = true } which = { workspace = true } diff --git a/crates/turborepo/tests/relationship_projection_contract.rs b/crates/turborepo/tests/relationship_projection_contract.rs index 895ae505f6e65..c2d2666c9f8e9 100644 --- a/crates/turborepo/tests/relationship_projection_contract.rs +++ b/crates/turborepo/tests/relationship_projection_contract.rs @@ -6,6 +6,11 @@ use std::{fs, path::Path}; use common::{combined_output, git, run_turbo, setup}; use serde_json::Value; +use turbopath::AbsoluteSystemPathBuf; +use turborepo_repository::{ + package_graph::{PackageGraph, PackageName, PackageNode, PruneDependencyMode}, + package_json::PackageJson, +}; const FIXTURE: &str = "relationship_projection_contract"; @@ -383,3 +388,143 @@ fn relation_names(package: &Value, relationship: &str) -> Vec { .collect(); strings(&Value::Array(names)) } + +#[tokio::test] +async fn typed_projections_match_the_contract_fixture_graph_and_prune_closures() { + let tempdir = tempfile::tempdir().unwrap(); + setup_fixture(tempdir.path()); + let root = AbsoluteSystemPathBuf::try_from(tempdir.path()).unwrap(); + let root_package_json = PackageJson::load(&root.join_component("package.json")).unwrap(); + let graph = PackageGraph::builder(&root, root_package_json) + .build() + .await + .unwrap(); + let app = PackageName::from("app"); + let app_node = PackageNode::Workspace(app.clone()); + + let mut graph_direct: Vec<_> = graph + .immediate_dependencies(&app_node) + .unwrap() + .into_iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) => Some(name.clone()), + PackageNode::Root => None, + }) + .collect(); + graph_direct.sort(); + assert_eq!( + graph + .ordering_relationships() + .direct_dependencies(&app) + .unwrap() + .cloned() + .collect::>(), + graph_direct + ); + + let mut graph_dependencies: Vec<_> = graph + .dependencies(&app_node) + .into_iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) if name != &app => Some(name.clone()), + PackageNode::Root | PackageNode::Workspace(_) => None, + }) + .collect(); + graph_dependencies.sort(); + assert_eq!( + graph + .filtering_relationships() + .transitive_dependencies(&app), + Some(graph_dependencies.clone()) + ); + assert_eq!( + graph.hash_relationships().dependency_inputs(&app), + Some(graph_dependencies) + ); + + let changed = PackageName::from("transitive-lib"); + let mut graph_affected: Vec<_> = graph + .ancestors(&PackageNode::Workspace(changed.clone())) + .into_iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) if name != &changed => Some(name.clone()), + PackageNode::Root | PackageNode::Workspace(_) => None, + }) + .collect(); + graph_affected.push(changed.clone()); + graph_affected.sort(); + assert_eq!( + graph + .affected_relationships() + .affected_by(std::slice::from_ref(&changed)), + Ok(graph_affected) + ); + + let root_input = PackageName::from("root-lib"); + let graph_root_ancestors = graph.ancestors(&PackageNode::Workspace(root_input.clone())); + let mut graph_root_dependents: Vec<_> = graph_root_ancestors + .iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) if name != &root_input => Some(name.clone()), + PackageNode::Root | PackageNode::Workspace(_) => None, + }) + .collect(); + graph_root_dependents.sort(); + assert_eq!( + graph + .filtering_relationships() + .transitive_dependents(&root_input), + Some(graph_root_dependents) + ); + let mut graph_root_affected: Vec<_> = graph_root_ancestors + .into_iter() + .filter_map(|node| match node { + PackageNode::Workspace(name) => Some(name.clone()), + PackageNode::Root => None, + }) + .collect(); + graph_root_affected.sort(); + assert_eq!( + graph + .affected_relationships() + .affected_by(std::slice::from_ref(&root_input)), + Ok(graph_root_affected) + ); + + assert_eq!( + graph.prune_relationships().package_closure( + std::slice::from_ref(&app), + PruneDependencyMode::IncludeDevDependencies, + ), + Ok([ + "//", + "app", + "dev-lib", + "optional-lib", + "prod-lib", + "required-peer", + "root-lib", + "transitive-lib", + ] + .into_iter() + .map(PackageName::from) + .collect()) + ); + assert_eq!( + graph + .prune_relationships() + .package_closure(&[app], PruneDependencyMode::ProductionOnly,), + Ok([ + "//", + "app", + "optional-lib", + "prod-lib", + "required-peer", + "root-lib", + "transitive-lib", + ] + .into_iter() + .map(PackageName::from) + .collect()) + ); +} From e6d36b9b750a3cf2f28c36151bb76cd13a292ee5 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 28 Jul 2026 12:19:35 -0700 Subject: [PATCH 2/2] style: Format test dependencies --- crates/turborepo/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/turborepo/Cargo.toml b/crates/turborepo/Cargo.toml index e4e2b4528f51e..d0018dd07eef4 100644 --- a/crates/turborepo/Cargo.toml +++ b/crates/turborepo/Cargo.toml @@ -28,9 +28,9 @@ nix = { workspace = true, features = ["signal"] } portable-pty = "0.9.0" pretty_assertions = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true } turbopath = { workspace = true } -tempfile = { workspace = true } which = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies]