Skip to content

Commit 0938e70

Browse files
authored
fix: Improve layouts and task graph sourcing for devtools" (#11269)
### Description Follow-up for #11263 - Fixing task graph sourcing to use the engine builder - Improve layouts, especially for large repos
1 parent 10b9099 commit 0938e70

File tree

11 files changed

+542
-436
lines changed

11 files changed

+542
-436
lines changed

Cargo.lock

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turborepo-devtools/Cargo.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ tower-http = { version = "0.5.2", features = ["cors"] }
2020
serde = { workspace = true, features = ["derive"] }
2121
serde_json = { workspace = true }
2222

23-
# JSON/JSONC parsing
24-
biome_json_parser = { workspace = true }
25-
biome_json_syntax = { workspace = true }
26-
2723
# File watching
2824
ignore = "0.4.22"
2925
notify = { workspace = true }

crates/turborepo-devtools/src/graph.rs

Lines changed: 1 addition & 307 deletions
Original file line numberDiff line numberDiff line change
@@ -3,129 +3,15 @@
33
//! Converts the internal PackageGraph (petgraph-based) to our
44
//! serializable PackageGraphData format for sending over WebSocket.
55
6-
use std::collections::HashSet;
7-
8-
use biome_json_parser::JsonParserOptions;
9-
use biome_json_syntax::JsonRoot;
10-
use tracing::debug;
11-
use turbopath::AbsoluteSystemPath;
126
use turborepo_repository::package_graph::{
137
PackageGraph, PackageName, PackageNode as RepoPackageNode,
148
};
159

16-
use crate::types::{GraphEdge, PackageGraphData, PackageNode, TaskGraphData, TaskNode};
10+
use crate::types::{GraphEdge, PackageGraphData, PackageNode};
1711

1812
/// Identifier used for the root package in the graph
1913
pub const ROOT_PACKAGE_ID: &str = "__ROOT__";
2014

21-
/// Reads task names from turbo.json at the repository root.
22-
/// Returns a set of task names (without package prefixes like "build", not
23-
/// "pkg#build"). Returns an empty set if turbo.json cannot be read or parsed.
24-
pub fn read_pipeline_tasks(repo_root: &AbsoluteSystemPath) -> HashSet<String> {
25-
let turbo_json_path = repo_root.join_component("turbo.json");
26-
let turbo_jsonc_path = repo_root.join_component("turbo.jsonc");
27-
28-
// Try turbo.json first, then turbo.jsonc
29-
let contents = turbo_json_path
30-
.read_to_string()
31-
.or_else(|_| turbo_jsonc_path.read_to_string());
32-
33-
match contents {
34-
Ok(contents) => parse_pipeline_tasks(&contents),
35-
Err(e) => {
36-
debug!("Could not read turbo.json: {}", e);
37-
HashSet::new()
38-
}
39-
}
40-
}
41-
42-
/// Parses turbo.json content and extracts task names.
43-
/// Task names like "build" or "pkg#build" are normalized to just the task part.
44-
fn parse_pipeline_tasks(contents: &str) -> HashSet<String> {
45-
// Use Biome's JSONC parser which handles comments natively
46-
let parse_result =
47-
biome_json_parser::parse_json(contents, JsonParserOptions::default().with_allow_comments());
48-
49-
if parse_result.has_errors() {
50-
debug!(
51-
"Failed to parse turbo.json: {:?}",
52-
parse_result.diagnostics()
53-
);
54-
return HashSet::new();
55-
}
56-
57-
let root: JsonRoot = parse_result.tree();
58-
59-
// Navigate to the "tasks" object and extract its keys
60-
extract_task_keys_from_json(&root)
61-
}
62-
63-
/// Extracts task keys from a parsed JSON root.
64-
/// Returns task names normalized (without package prefixes).
65-
fn extract_task_keys_from_json(root: &JsonRoot) -> HashSet<String> {
66-
use biome_json_syntax::AnyJsonValue;
67-
68-
// Get the root value (should be an object)
69-
let Some(value) = root.value().ok() else {
70-
return HashSet::new();
71-
};
72-
73-
let AnyJsonValue::JsonObjectValue(obj) = value else {
74-
return HashSet::new();
75-
};
76-
77-
// Find the "tasks" member
78-
for member in obj.json_member_list() {
79-
let Ok(member) = member else { continue };
80-
let Ok(name) = member.name() else { continue };
81-
82-
if get_member_name_text(&name) == "tasks" {
83-
let Ok(tasks_value) = member.value() else {
84-
continue;
85-
};
86-
87-
if let AnyJsonValue::JsonObjectValue(tasks_obj) = tasks_value {
88-
let mut task_names = HashSet::new();
89-
extract_keys_from_object(&tasks_obj, &mut task_names);
90-
return task_names;
91-
}
92-
}
93-
}
94-
95-
HashSet::new()
96-
}
97-
98-
/// Helper to get the text content of a JSON member name
99-
fn get_member_name_text(name: &biome_json_syntax::JsonMemberName) -> String {
100-
// The name is a string literal, we need to extract the text without quotes
101-
name.inner_string_text()
102-
.map(|t| t.to_string())
103-
.unwrap_or_default()
104-
}
105-
106-
/// Extracts keys from a JSON object and normalizes task names
107-
fn extract_keys_from_object(
108-
obj: &biome_json_syntax::JsonObjectValue,
109-
task_names: &mut HashSet<String>,
110-
) {
111-
for member in obj.json_member_list() {
112-
let Ok(member) = member else { continue };
113-
let Ok(name) = member.name() else { continue };
114-
115-
let task_name = get_member_name_text(&name);
116-
117-
// Strip package prefix if present (e.g., "pkg#build" -> "build")
118-
// Also handle root tasks like "//#build" -> "build"
119-
let normalized = if let Some(pos) = task_name.find('#') {
120-
task_name[pos + 1..].to_string()
121-
} else {
122-
task_name
123-
};
124-
125-
task_names.insert(normalized);
126-
}
127-
}
128-
12915
/// Converts a PackageGraph to our serializable PackageGraphData format.
13016
pub fn package_graph_to_data(pkg_graph: &PackageGraph) -> PackageGraphData {
13117
let mut nodes = Vec::new();
@@ -183,93 +69,6 @@ pub fn package_graph_to_data(pkg_graph: &PackageGraph) -> PackageGraphData {
18369
PackageGraphData { nodes, edges }
18470
}
18571

186-
/// Converts a PackageGraph to a task-level graph.
187-
///
188-
/// Creates a node for each package#script combination found in the monorepo.
189-
/// Edges are created based on package dependencies - if package A depends on
190-
/// package B, then for tasks defined in `pipeline_tasks`, A#task depends on
191-
/// B#task.
192-
///
193-
/// The `pipeline_tasks` parameter should contain task names from turbo.json's
194-
/// tasks configuration. Use `read_pipeline_tasks` to obtain these from the
195-
/// repository's turbo.json file.
196-
pub fn task_graph_to_data(
197-
pkg_graph: &PackageGraph,
198-
pipeline_tasks: &HashSet<String>,
199-
) -> TaskGraphData {
200-
let mut nodes = Vec::new();
201-
let mut edges = Vec::new();
202-
203-
// First pass: collect all tasks and create nodes
204-
for (name, info) in pkg_graph.packages() {
205-
let package_id = match name {
206-
PackageName::Root => ROOT_PACKAGE_ID.to_string(),
207-
PackageName::Other(n) => n.clone(),
208-
};
209-
210-
for (script_name, script_cmd) in info.package_json.scripts.iter() {
211-
let task_id = format!("{}#{}", package_id, script_name);
212-
nodes.push(TaskNode {
213-
id: task_id,
214-
package: package_id.clone(),
215-
task: script_name.clone(),
216-
script: script_cmd.value.clone(),
217-
});
218-
}
219-
}
220-
221-
// Second pass: create edges based on package dependencies
222-
// For tasks defined in turbo.json, if package A depends on package B,
223-
// then A#task -> B#task
224-
for (name, info) in pkg_graph.packages() {
225-
let package_id = match name {
226-
PackageName::Root => ROOT_PACKAGE_ID.to_string(),
227-
PackageName::Other(n) => n.clone(),
228-
};
229-
230-
let pkg_node = RepoPackageNode::Workspace(name.clone());
231-
232-
if let Some(deps) = pkg_graph.immediate_dependencies(&pkg_node) {
233-
for dep in deps {
234-
// Skip the synthetic Root node
235-
if matches!(dep, RepoPackageNode::Root) {
236-
continue;
237-
}
238-
239-
let dep_id = match dep {
240-
RepoPackageNode::Root => continue,
241-
RepoPackageNode::Workspace(dep_name) => match dep_name {
242-
PackageName::Root => ROOT_PACKAGE_ID.to_string(),
243-
PackageName::Other(n) => n.clone(),
244-
},
245-
};
246-
247-
// Get scripts from the dependency package
248-
let dep_info = match dep {
249-
RepoPackageNode::Root => continue,
250-
RepoPackageNode::Workspace(dep_name) => pkg_graph.package_info(dep_name),
251-
};
252-
253-
if let Some(dep_info) = dep_info {
254-
// For pipeline tasks that exist in both packages, create edges
255-
for script in info.package_json.scripts.keys() {
256-
if pipeline_tasks.contains(script)
257-
&& dep_info.package_json.scripts.contains_key(script)
258-
{
259-
edges.push(GraphEdge {
260-
source: format!("{}#{}", package_id, script),
261-
target: format!("{}#{}", dep_id, script),
262-
});
263-
}
264-
}
265-
}
266-
}
267-
}
268-
}
269-
270-
TaskGraphData { nodes, edges }
271-
}
272-
27372
#[cfg(test)]
27473
mod tests {
27574
use super::*;
@@ -278,109 +77,4 @@ mod tests {
27877
fn test_root_package_id() {
27978
assert_eq!(ROOT_PACKAGE_ID, "__ROOT__");
28079
}
281-
282-
#[test]
283-
fn test_parse_pipeline_tasks_basic() {
284-
let turbo_json = r#"
285-
{
286-
"tasks": {
287-
"build": {},
288-
"test": {},
289-
"lint": {}
290-
}
291-
}
292-
"#;
293-
let tasks = parse_pipeline_tasks(turbo_json);
294-
assert!(tasks.contains("build"));
295-
assert!(tasks.contains("test"));
296-
assert!(tasks.contains("lint"));
297-
assert_eq!(tasks.len(), 3);
298-
}
299-
300-
#[test]
301-
fn test_parse_pipeline_tasks_with_package_prefix() {
302-
let turbo_json = r#"
303-
{
304-
"tasks": {
305-
"build": {},
306-
"web#build": {},
307-
"//#test": {}
308-
}
309-
}
310-
"#;
311-
let tasks = parse_pipeline_tasks(turbo_json);
312-
// Both "build" and "web#build" should normalize to "build"
313-
assert!(tasks.contains("build"));
314-
assert!(tasks.contains("test"));
315-
// Should only have 2 unique task names after normalization
316-
assert_eq!(tasks.len(), 2);
317-
}
318-
319-
#[test]
320-
fn test_parse_pipeline_tasks_with_comments() {
321-
let turbo_json = r#"
322-
{
323-
// This is a comment
324-
"tasks": {
325-
"build": {}, /* inline comment */
326-
"compile": {}
327-
}
328-
}
329-
"#;
330-
let tasks = parse_pipeline_tasks(turbo_json);
331-
assert!(tasks.contains("build"));
332-
assert!(tasks.contains("compile"));
333-
assert_eq!(tasks.len(), 2);
334-
}
335-
336-
#[test]
337-
fn test_parse_pipeline_tasks_empty() {
338-
let turbo_json = r#"
339-
{
340-
"tasks": {}
341-
}
342-
"#;
343-
let tasks = parse_pipeline_tasks(turbo_json);
344-
// Empty tasks object should return empty set
345-
assert!(tasks.is_empty());
346-
}
347-
348-
#[test]
349-
fn test_parse_pipeline_tasks_no_tasks_key() {
350-
let turbo_json = r#"
351-
{
352-
"globalEnv": ["NODE_ENV"]
353-
}
354-
"#;
355-
let tasks = parse_pipeline_tasks(turbo_json);
356-
// No tasks key should return empty set
357-
assert!(tasks.is_empty());
358-
}
359-
360-
#[test]
361-
fn test_parse_pipeline_tasks_invalid_json() {
362-
let turbo_json = r#"{ invalid json }"#;
363-
let tasks = parse_pipeline_tasks(turbo_json);
364-
// Invalid JSON should return empty set
365-
assert!(tasks.is_empty());
366-
}
367-
368-
#[test]
369-
fn test_parse_pipeline_tasks_custom_tasks() {
370-
let turbo_json = r#"
371-
{
372-
"tasks": {
373-
"compile": {},
374-
"bundle": {},
375-
"deploy": {}
376-
}
377-
}
378-
"#;
379-
let tasks = parse_pipeline_tasks(turbo_json);
380-
assert!(tasks.contains("compile"));
381-
assert!(tasks.contains("bundle"));
382-
assert!(tasks.contains("deploy"));
383-
// Should NOT contain defaults since we found tasks
384-
assert!(!tasks.contains("lint"));
385-
}
38680
}

0 commit comments

Comments
 (0)