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 ;
126use 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
1913pub 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.
13016pub 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) ]
27473mod 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