Skip to content

Commit d36d363

Browse files
feat(turbo_json): add support for extending from non-root
1 parent cc4ff1b commit d36d363

13 files changed

+248
-31
lines changed

crates/turborepo-lib/src/config/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ pub enum Error {
140140
#[source_code]
141141
text: NamedSource<String>,
142142
},
143+
#[error("You must extend from the root of the workspace first.")]
144+
ExtendsRootFirst {
145+
#[label("'//' should be first")]
146+
span: Option<SourceSpan>,
147+
#[source_code]
148+
text: NamedSource<String>,
149+
},
143150
#[error("`{field}` cannot contain an environment variable.")]
144151
InvalidDependsOnValue {
145152
field: &'static str,

crates/turborepo-lib/src/engine/builder.rs

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use super::Engine;
1313
use crate::{
1414
config,
1515
task_graph::TaskDefinition,
16-
turbo_json::{validator::Validator, ProcessedTaskDefinition, TurboJson, TurboJsonLoader},
16+
turbo_json::{
17+
validator::Validator, FutureFlags, ProcessedTaskDefinition, TurboJson, TurboJsonLoader,
18+
},
1719
};
1820

1921
#[derive(Debug, thiserror::Error, Diagnostic)]
@@ -93,6 +95,16 @@ pub struct MissingTurboJsonExtends {
9395
text: NamedSource<String>,
9496
}
9597

98+
#[derive(Debug, thiserror::Error, Diagnostic)]
99+
#[error("Cyclic extends detected: {}", cycle.join(" -> "))]
100+
pub struct CyclicExtends {
101+
cycle: Vec<String>,
102+
#[label("Cycle detected here")]
103+
span: Option<SourceSpan>,
104+
#[source_code]
105+
text: NamedSource<String>,
106+
}
107+
96108
#[derive(Debug, thiserror::Error, Diagnostic)]
97109
pub enum Error {
98110
#[error("Missing tasks in project")]
@@ -113,6 +125,9 @@ pub enum Error {
113125
MissingTurboJsonExtends(Box<MissingTurboJsonExtends>),
114126
#[error(transparent)]
115127
#[diagnostic(transparent)]
128+
CyclicExtends(Box<CyclicExtends>),
129+
#[error(transparent)]
130+
#[diagnostic(transparent)]
116131
Config(#[from] crate::config::Error),
117132
#[error("Invalid turbo.json configuration")]
118133
Validation {
@@ -137,6 +152,7 @@ pub struct EngineBuilder<'a> {
137152
tasks_only: bool,
138153
add_all_tasks: bool,
139154
should_validate_engine: bool,
155+
validator: Validator,
140156
}
141157

142158
impl<'a> EngineBuilder<'a> {
@@ -157,9 +173,15 @@ impl<'a> EngineBuilder<'a> {
157173
tasks_only: false,
158174
add_all_tasks: false,
159175
should_validate_engine: true,
176+
validator: Validator::new(),
160177
}
161178
}
162179

180+
pub fn with_future_flags(mut self, future_flags: FutureFlags) -> Self {
181+
self.validator = self.validator.with_future_flags(future_flags);
182+
self
183+
}
184+
163185
pub fn with_tasks_only(mut self, tasks_only: bool) -> Self {
164186
self.tasks_only = tasks_only;
165187
self
@@ -591,8 +613,9 @@ impl<'a> EngineBuilder<'a> {
591613
task_name: &TaskName,
592614
) -> Result<Vec<ProcessedTaskDefinition>, Error> {
593615
let package_name = PackageName::from(task_id.package());
594-
let mut turbo_json_chain =
595-
Self::turbo_json_chain(turbo_json_loader, &package_name)?.into_iter();
616+
let mut turbo_json_chain = self
617+
.turbo_json_chain(turbo_json_loader, &package_name)?
618+
.into_iter();
596619
let mut task_definitions = Vec::new();
597620

598621
if let Some(root_definition) = turbo_json_chain
@@ -619,7 +642,7 @@ impl<'a> EngineBuilder<'a> {
619642
};
620643
}
621644

622-
if let Some(turbo_json) = turbo_json_chain.next() {
645+
for turbo_json in turbo_json_chain {
623646
if let Some(workspace_def) = turbo_json.task(task_id, task_name)? {
624647
task_definitions.push(workspace_def);
625648
}
@@ -643,10 +666,11 @@ impl<'a> EngineBuilder<'a> {
643666
// Provide the chain of turbo.json's to load to fully resolve all extends for a
644667
// package turbo.json.
645668
fn turbo_json_chain<'b>(
669+
&self,
646670
turbo_json_loader: &'b TurboJsonLoader,
647671
package_name: &PackageName,
648672
) -> Result<Vec<&'b TurboJson>, Error> {
649-
let validator = Validator::new();
673+
let validator = &self.validator;
650674
let mut turbo_jsons = Vec::with_capacity(2);
651675

652676
enum ReadReq {
@@ -673,10 +697,37 @@ impl<'a> EngineBuilder<'a> {
673697
}
674698
}
675699

676-
let mut read_stack = vec![ReadReq::Infer(package_name.clone())];
700+
let mut read_stack = vec![(ReadReq::Infer(package_name.clone()), vec![])];
701+
let mut visited = std::collections::HashSet::new();
677702

678-
while let Some(read_req) = read_stack.pop() {
703+
while let Some((read_req, mut path)) = read_stack.pop() {
679704
let package_name = read_req.package_name();
705+
706+
// Check for cycle by seeing if this package is already in the current path
707+
if let Some(cycle_index) = path.iter().position(|p: &PackageName| p == package_name) {
708+
// Found a cycle - build the cycle portion for error
709+
let mut cycle = path[cycle_index..]
710+
.iter()
711+
.map(|p| p.to_string())
712+
.collect::<Vec<_>>();
713+
cycle.push(package_name.to_string());
714+
715+
let (span, text) = read_req
716+
.required()
717+
.unwrap_or_else(|| (None, NamedSource::new("turbo.json", String::new())));
718+
719+
return Err(Error::CyclicExtends(Box::new(CyclicExtends {
720+
cycle,
721+
span,
722+
text,
723+
})));
724+
}
725+
726+
// Skip if we've already fully processed this package
727+
if visited.contains(package_name) {
728+
continue;
729+
}
730+
680731
let turbo_json = turbo_json_loader
681732
.load(package_name)
682733
.map(Some)
@@ -700,15 +751,23 @@ impl<'a> EngineBuilder<'a> {
700751
if let Some(turbo_json) = turbo_json {
701752
Error::from_validation(validator.validate_turbo_json(package_name, turbo_json))?;
702753
turbo_jsons.push(turbo_json);
754+
visited.insert(package_name.clone());
755+
756+
// Add current package to path for cycle detection
757+
path.push(package_name.clone());
758+
703759
// Add the new turbo.json we are extending from
704760
let (extends, span) = turbo_json.extends.clone().split();
705-
for package_name in extends {
706-
let package_name = PackageName::from(package_name);
707-
read_stack.push(ReadReq::Request(span.clone().to(package_name)));
761+
for extend_package in extends {
762+
let extend_package_name = PackageName::from(extend_package);
763+
read_stack.push((
764+
ReadReq::Request(span.clone().to(extend_package_name)),
765+
path.clone(),
766+
));
708767
}
709768
} else if turbo_jsons.is_empty() {
710769
// If there is no package turbo.json extend from root by default
711-
read_stack.push(ReadReq::Infer(PackageName::Root));
770+
read_stack.push((ReadReq::Infer(PackageName::Root), path));
712771
}
713772
}
714773

@@ -1739,4 +1798,63 @@ mod test {
17391798
"../.."
17401799
);
17411800
}
1801+
1802+
#[test]
1803+
fn test_cyclic_extends() {
1804+
let repo_root_dir = TempDir::with_prefix("repo").unwrap();
1805+
let repo_root = AbsoluteSystemPathBuf::new(repo_root_dir.path().to_str().unwrap()).unwrap();
1806+
let package_graph = mock_package_graph(
1807+
&repo_root,
1808+
package_jsons! {
1809+
repo_root,
1810+
"app1" => [],
1811+
"app2" => []
1812+
},
1813+
);
1814+
1815+
// Create a self-referencing cycle: Root extends itself
1816+
let turbo_jsons = vec![
1817+
(
1818+
PackageName::Root,
1819+
turbo_json(json!({
1820+
"extends": ["//"], // Root extending itself creates a cycle
1821+
"tasks": {
1822+
"build": {}
1823+
}
1824+
})),
1825+
),
1826+
(
1827+
PackageName::from("app1"),
1828+
turbo_json(json!({
1829+
"extends": ["//"],
1830+
"tasks": {}
1831+
})),
1832+
),
1833+
(
1834+
PackageName::from("app2"),
1835+
turbo_json(json!({
1836+
"extends": ["//"],
1837+
"tasks": {}
1838+
})),
1839+
),
1840+
]
1841+
.into_iter()
1842+
.collect();
1843+
1844+
let loader = TurboJsonLoader::noop(turbo_jsons);
1845+
let engine_result = EngineBuilder::new(&repo_root, &package_graph, &loader, false)
1846+
.with_tasks(Some(Spanned::new(TaskName::from("build"))))
1847+
.with_workspaces(vec![PackageName::from("app1")])
1848+
.build();
1849+
1850+
assert!(engine_result.is_err());
1851+
if let Err(Error::CyclicExtends(box CyclicExtends { cycle, .. })) = engine_result {
1852+
// The cycle should contain root (//) since it's a self-reference
1853+
assert!(cycle.contains(&"//".to_string()));
1854+
// Should have at least 2 entries to show the cycle (// -> //)
1855+
assert!(cycle.len() >= 2);
1856+
} else {
1857+
panic!("Expected CyclicExtends error, got {:?}", engine_result);
1858+
}
1859+
}
17421860
}

crates/turborepo-lib/src/run/builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ impl RunBuilder {
541541
.with_root_tasks(root_turbo_json.tasks.keys().cloned())
542542
.with_tasks_only(self.opts.run_opts.only)
543543
.with_workspaces(filtered_pkgs.cloned().collect())
544+
.with_future_flags(self.opts.future_flags)
544545
.with_tasks(tasks);
545546

546547
if self.add_all_tasks {

crates/turborepo-lib/src/turbo_json/future_flags.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,20 @@ use struct_iterable::Iterable;
2828
/// before it becomes the default behavior in a future version.
2929
#[derive(Serialize, Default, Debug, Copy, Clone, Iterable, Deserializable, PartialEq, Eq)]
3030
#[serde(rename_all = "camelCase")]
31+
#[deserializable()]
3132
pub struct FutureFlags {
3233
/// Enable `$TURBO_EXTENDS$`
3334
///
3435
/// When enabled, allows using `$TURBO_EXTENDS$` in array fields.
3536
/// This will change the default behavior of overriding the field to instead
3637
/// append.
3738
pub turbo_extends_keyword: bool,
39+
/// Enable extending from a non-root `turbo.json`
40+
///
41+
/// When enabled, allows using extends targeting `turbo.json`s other than
42+
/// root. All `turbo.json` must still extend from the root `turbo.json`
43+
/// first.
44+
pub non_root_extends: bool,
3845
}
3946

4047
impl FutureFlags {

crates/turborepo-lib/src/turbo_json/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,8 @@ mod tests {
10221022
assert_eq!(
10231023
future_flags.as_inner(),
10241024
&FutureFlags {
1025-
turbo_extends_keyword: true
1025+
turbo_extends_keyword: true,
1026+
non_root_extends: false,
10261027
}
10271028
);
10281029

crates/turborepo-lib/src/turbo_json/processed.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ mod tests {
407407
items,
408408
&FutureFlags {
409409
turbo_extends_keyword: true,
410+
non_root_extends: false,
410411
},
411412
);
412413

@@ -428,6 +429,7 @@ mod tests {
428429
items,
429430
&FutureFlags {
430431
turbo_extends_keyword: false,
432+
non_root_extends: false,
431433
},
432434
);
433435

@@ -447,6 +449,7 @@ mod tests {
447449
items,
448450
&FutureFlags {
449451
turbo_extends_keyword: true,
452+
non_root_extends: false,
450453
},
451454
);
452455

@@ -592,6 +595,7 @@ mod tests {
592595
raw_globs,
593596
&FutureFlags {
594597
turbo_extends_keyword: true,
598+
non_root_extends: false,
595599
},
596600
)
597601
.unwrap();
@@ -615,6 +619,7 @@ mod tests {
615619
raw_env,
616620
&FutureFlags {
617621
turbo_extends_keyword: false,
622+
non_root_extends: false,
618623
},
619624
);
620625
assert!(result.is_err());
@@ -634,6 +639,7 @@ mod tests {
634639
Spanned::new(raw_deps),
635640
&FutureFlags {
636641
turbo_extends_keyword: false,
642+
non_root_extends: false,
637643
},
638644
);
639645
assert!(result.is_err());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: crates/turborepo-lib/src/turbo_json/validator.rs
3+
expression: error_messages
4+
---
5+
[
6+
"You must extend from the root of the workspace first.",
7+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: crates/turborepo-lib/src/turbo_json/validator.rs
3+
expression: error_messages
4+
---
5+
[
6+
"You must extend from the root of the workspace first.",
7+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: crates/turborepo-lib/src/turbo_json/validator.rs
3+
expression: error_messages
4+
---
5+
[]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: crates/turborepo-lib/src/turbo_json/validator.rs
3+
expression: error_messages
4+
---
5+
[
6+
"You must extend from the root of the workspace first.",
7+
]

0 commit comments

Comments
 (0)