@@ -591,23 +591,37 @@ function parseWebpackText(
591591// turbo
592592// ---------------------------------------------------------------------------
593593
594- // Turbo task line format: <package>#<task>
595- // e.g. "@paretools/shared#build: cache hit, replaying logs abc123 (100ms)"
596- // or "@paretools/git#build: cache miss, executing abc123 (2.5s)"
597- // or "web#build: computed xyz ... (1.2s)"
598- // Summary line: "Tasks: 5 successful, 5 total"
599- // Cached line: "Cached: 3 cached, 5 total"
600- // Duration line:"Duration: 2.5s"
601-
602- // Match task status lines like:
603- // @paretools /shared#build: cache hit, replaying logs ... (100ms)
604- // @scope /pkg#test: cache miss, executing ... (2.5s)
605- // myapp#lint: cache bypass (5.1s)
606- const TURBO_TASK_RE = / ^ ( .+ ?) # ( \S + ) : \s + c a c h e \s + ( h i t | m i s s | b y p a s s ) (?: , \s + \w + [ ^ ( ] * ) ? \( ( [ ^ ) ] + ) \) / ;
607-
608- // Match task failure lines:
609- // @scope /pkg#build: command ... exited (1)
610- // pkg#test: ERROR ... (500ms)
594+ // Turbo task line format: <package>:<task> (modern turbo 2.x) or <package>#<task> (legacy).
595+ // Modern turbo (2.x) prefixes log lines with "<pkg>:<task>:" and emits the
596+ // status line as part of the same prefix:
597+ // "@paretools/shared:build: cache hit, replaying logs abc123"
598+ // "@paretools/shared:build: cache miss, executing abc123"
599+ // "@paretools/shared:build: cache bypass, force executing abc123"
600+ // Older turbo versions used "#" and frequently appended a "(duration)" suffix:
601+ // "@scope/pkg#test: cache miss, executing abc123 (2.5s)"
602+ // "myapp#lint: cache bypass (5.1s)"
603+ // Failure summary lines (still emitted with "#" today):
604+ // " ERROR @scope/pkg#build: command (...) ... exited (1)"
605+ // " Failed: @scope/pkg#build"
606+ // Summary lines:
607+ // "Tasks: 5 successful, 5 total"
608+ // "Cached: 3 cached, 5 total"
609+
610+ // Match per-task status lines. Accepts either ":" or "#" between package and
611+ // task name and treats the trailing "(duration)" as optional, so it works for
612+ // both legacy and modern turbo CLIs.
613+ const TURBO_TASK_RE = / ^ ( .+ ?) [ # : ] ( \S + ) : \s + c a c h e \s + ( h i t | m i s s | b y p a s s ) (?: , [ ^ ( ] * ) ? (?: \( ( [ ^ ) ] + ) \) ) ? / ;
614+
615+ // Failure lines from the per-task ERROR summary that turbo prints near the end
616+ // of a failed run. Modern turbo emits:
617+ // " ERROR @scope/pkg#build: command (...) ... exited (2)"
618+ // and a one-per-line "Failed:" listing:
619+ // " Failed: @scope/pkg#build"
620+ const TURBO_ERROR_LINE_RE = / (?: ^ | \s ) E R R O R \s + ( .+ ?) # ( \S + ) : .* e x i t e d \s * \( \d + \) / ;
621+ const TURBO_FAILED_LIST_RE = / ^ \s * F a i l e d : \s + ( .+ ?) # ( \S + ) \s * $ / ;
622+ // Legacy/inline task failure lines:
623+ // "@scope/pkg#build: command ... exited (1)"
624+ // "pkg#test: ERROR ... (500ms)"
611625const TURBO_TASK_FAIL_RE = / ^ ( .+ ?) # ( \S + ) : .* (?: e x i t e d \s * \( \d + \) | E R R O R ) / ;
612626
613627// Summary lines
@@ -645,57 +659,83 @@ export function parseTurboOutput(
645659 duration : number ,
646660 summaryJsonContent ?: string ,
647661) : TurboResult {
648- const tasks : TurboTask [ ] = [ ] ;
649- const seenTasks = new Set < string > ( ) ;
662+ const taskMap = new Map < string , TurboTask > ( ) ;
650663 const combined = stdout + "\n" + stderr ;
651664 const lines = combined . split ( "\n" ) ;
652665
653666 let summaryTotal = 0 ;
667+ let summarySuccessful = 0 ;
654668 let summaryCached = 0 ;
669+ let sawSummaryLine = false ;
670+
671+ const upsertTask = ( pkg : string , task : string , patch : Partial < TurboTask > ) => {
672+ const key = `${ pkg } #${ task } ` ;
673+ const existing = taskMap . get ( key ) ;
674+ if ( existing ) {
675+ // Failure information takes precedence over a previously-seen success line.
676+ if ( patch . status === "fail" ) existing . status = "fail" ;
677+ if ( patch . cache !== undefined && existing . cache === undefined ) existing . cache = patch . cache ;
678+ if ( patch . duration !== undefined && existing . duration === undefined )
679+ existing . duration = patch . duration ;
680+ if ( patch . durationMs !== undefined && existing . durationMs === undefined )
681+ existing . durationMs = patch . durationMs ;
682+ return ;
683+ }
684+ taskMap . set ( key , {
685+ package : pkg ,
686+ task : task ,
687+ status : patch . status ?? "pass" ,
688+ duration : patch . duration ,
689+ durationMs : patch . durationMs ,
690+ cache : patch . cache ,
691+ } ) ;
692+ } ;
655693
656694 for ( const line of lines ) {
657695 const trimmed = line . trim ( ) ;
658696
659- // Check for task status lines
697+ // Check for failure lines first so the ERROR/Failed prefix wins over any
698+ // earlier "cache bypass" status line for the same task.
699+ const errorMatch = trimmed . match ( TURBO_ERROR_LINE_RE ) ;
700+ if ( errorMatch ) {
701+ upsertTask ( errorMatch [ 1 ] , errorMatch [ 2 ] , { status : "fail" , cache : "miss" } ) ;
702+ continue ;
703+ }
704+
705+ const failedListMatch = trimmed . match ( TURBO_FAILED_LIST_RE ) ;
706+ if ( failedListMatch ) {
707+ upsertTask ( failedListMatch [ 1 ] , failedListMatch [ 2 ] , { status : "fail" , cache : "miss" } ) ;
708+ continue ;
709+ }
710+
711+ // Per-task status line (covers both legacy "#" and modern ":" separators).
660712 const taskMatch = trimmed . match ( TURBO_TASK_RE ) ;
661713 if ( taskMatch ) {
662- const key = `${ taskMatch [ 1 ] } #${ taskMatch [ 2 ] } ` ;
663- if ( ! seenTasks . has ( key ) ) {
664- seenTasks . add ( key ) ;
665- const durationStr = taskMatch [ 4 ] ;
666- const durationMs = parseDurationToMs ( durationStr ) ;
667- tasks . push ( {
668- package : taskMatch [ 1 ] ,
669- task : taskMatch [ 2 ] ,
670- status : "pass" ,
671- duration : durationStr ,
672- durationMs,
673- cache : taskMatch [ 3 ] === "hit" ? "hit" : "miss" ,
674- } ) ;
675- }
714+ const durationStr = taskMatch [ 4 ] ;
715+ const durationMs = durationStr ? parseDurationToMs ( durationStr ) : undefined ;
716+ upsertTask ( taskMatch [ 1 ] , taskMatch [ 2 ] , {
717+ status : "pass" ,
718+ duration : durationStr ,
719+ durationMs,
720+ // Treat both "miss" and "bypass" as a cache miss for accounting.
721+ cache : taskMatch [ 3 ] === "hit" ? "hit" : "miss" ,
722+ } ) ;
676723 continue ;
677724 }
678725
679- // Check for failure lines
726+ // Inline failure line (legacy turbo format).
680727 const failMatch = trimmed . match ( TURBO_TASK_FAIL_RE ) ;
681728 if ( failMatch ) {
682- const key = `${ failMatch [ 1 ] } #${ failMatch [ 2 ] } ` ;
683- if ( ! seenTasks . has ( key ) ) {
684- seenTasks . add ( key ) ;
685- tasks . push ( {
686- package : failMatch [ 1 ] ,
687- task : failMatch [ 2 ] ,
688- status : "fail" ,
689- cache : "miss" ,
690- } ) ;
691- }
729+ upsertTask ( failMatch [ 1 ] , failMatch [ 2 ] , { status : "fail" , cache : "miss" } ) ;
692730 continue ;
693731 }
694732
695- // Check for summary lines
733+ // Summary lines
696734 const summaryMatch = trimmed . match ( TURBO_TASKS_SUMMARY_RE ) ;
697735 if ( summaryMatch ) {
736+ summarySuccessful = parseInt ( summaryMatch [ 1 ] , 10 ) ;
698737 summaryTotal = parseInt ( summaryMatch [ 2 ] , 10 ) ;
738+ sawSummaryLine = true ;
699739 continue ;
700740 }
701741
@@ -705,10 +745,27 @@ export function parseTurboOutput(
705745 }
706746 }
707747
708- const passed = tasks . filter ( ( t ) => t . status === "pass" ) . length ;
709- const failed = tasks . filter ( ( t ) => t . status === "fail" ) . length ;
710- const cached = tasks . filter ( ( t ) => t . cache === "hit" ) . length ;
711- const totalTasks = summaryTotal || tasks . length ;
748+ const tasks = Array . from ( taskMap . values ( ) ) ;
749+
750+ // Prefer the explicit summary line counts when available — they are the
751+ // ground truth from turbo and let us preserve the
752+ // `passed + failed === totalTasks` invariant even if individual per-task
753+ // status lines couldn't be matched (e.g. log truncation, future format
754+ // changes).
755+ let passed = tasks . filter ( ( t ) => t . status === "pass" ) . length ;
756+ let failed = tasks . filter ( ( t ) => t . status === "fail" ) . length ;
757+ let totalTasks = tasks . length ;
758+ if ( sawSummaryLine ) {
759+ totalTasks = summaryTotal ;
760+ passed = summarySuccessful ;
761+ failed = Math . max ( 0 , summaryTotal - summarySuccessful ) ;
762+ }
763+
764+ // Cached: prefer the summary "Cached: N cached" line; fall back to counting
765+ // per-task hits.
766+ const cachedFromTasks = tasks . filter ( ( t ) => t . cache === "hit" ) . length ;
767+ const cached = sawSummaryLine ? summaryCached : cachedFromTasks ;
768+
712769 let summary : Record < string , unknown > | undefined ;
713770 if ( summaryJsonContent ) {
714771 try {
@@ -728,7 +785,7 @@ export function parseTurboOutput(
728785 totalTasks,
729786 passed,
730787 failed,
731- cached : summaryCached || cached ,
788+ cached,
732789 summary,
733790 } ;
734791}
0 commit comments