@@ -2,9 +2,8 @@ import { StringDecoder } from 'node:string_decoder';
22
33import { FAILURE_PATTERNS } from './ci_failure_parser.js' ;
44
5- // Drop stateful flags: repeated streaming checks must not share lastIndex state
6- // with the whole-log parser or with another scanner. Preserve matching flags.
75function createMatcher ( patterns ) {
6+ // Preserve matching flags without sharing RegExp.lastIndex between scans.
87 const matchers = patterns . map ( pattern =>
98 new RegExp ( pattern . source , pattern . flags . replace ( / [ g y ] / g, '' ) ) ) ;
109 return text => matchers . some ( pattern => pattern . test ( text ) ) ;
@@ -13,123 +12,142 @@ function createMatcher(patterns) {
1312const diagnostic = createMatcher ( FAILURE_PATTERNS . diagnostic ) ;
1413const infrastructure = createMatcher ( FAILURE_PATTERNS . infrastructure ) ;
1514
16- export class FailureFileScanner {
17- constructor ( filenames ) {
18- this . windowSize = 256 ;
19- this . matchers = [ ...filenames ] . map ( filename => {
20- const paths = new Set ( [ filename ] ) ;
21- if ( filename . startsWith ( 'test/' ) ) {
22- paths . add ( filename . slice ( 5 ) ) ;
23- paths . add ( filename . slice ( 5 ) . replace ( / \. (?: j s | m j s | c j s | o u t ) $ / , '' ) ) ;
24- } else if ( filename . startsWith ( 'lib/' ) ) {
25- paths . add ( filename . slice ( 4 ) ) ;
26- }
27- const patterns = [ ...paths ] . map ( path => {
28- this . windowSize = Math . max ( this . windowSize , path . length + 2 ) ;
29- const escaped = path . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
30- // Require a real delimiter, rather than treating a chunk end as EOF.
31- return new RegExp ( `(?:^|[^\\w.-])${ escaped } (?=[^\\w./-])` ) ;
32- } ) ;
33- return { filename, patterns } ;
34- } ) ;
35- this . line = { } ;
36- this . tail = '' ;
37- this . history = [ ] ;
38- this . context = 0 ;
39- this . tap = null ;
40- this . git = null ;
15+ function fileAliases ( filename ) {
16+ const paths = new Set ( [ filename ] ) ;
17+ if ( filename . startsWith ( 'test/' ) ) {
18+ paths . add ( filename . slice ( 5 ) ) ;
19+ paths . add ( filename . slice ( 5 ) . replace ( / \. (?: j s | m j s | c j s | o u t ) $ / , '' ) ) ;
20+ } else if ( filename . startsWith ( 'lib/' ) ) {
21+ paths . add ( filename . slice ( 4 ) ) ;
4122 }
23+ return [ ...paths ] ;
24+ }
25+
26+ // Emit overlapping, normalized windows rather than buffering whole log lines.
27+ // Explicit line boundaries keep network chunk boundaries out of the matching rules.
28+ async function * logWindows ( source , overlap ) {
29+ const decoder = new StringDecoder ( 'utf8' ) ;
30+ let tail = '' ;
31+ let lineStart = true ;
32+ let afterBackslash = false ;
4233
43- fragment ( raw , end ) {
44- // Normalize literal and escaped Windows separators, including runs split
45- // across network chunks. Preserve UTF-8 with StringDecoder in scan().
34+ function * consume ( raw ) {
35+ raw = raw . replace ( / \r / g, '' ) ;
4636 let text = raw . replace ( / \\ + / g, '/' ) ;
47- if ( this . backslash && raw . startsWith ( '\\' ) ) text = text . slice ( 1 ) ;
48- if ( raw ) this . backslash = raw . endsWith ( '\\' ) ;
49- const line = this . line ;
50- line . prefix = ( ( line . prefix ?? '' ) + text ) . slice ( 0 , 128 ) ;
51- const window = ( this . continuation ? 'x' : '' ) + this . tail + text + ( end ? '\n' : '' ) ;
52- line . match ??= this . matchers . find ( ( { patterns } ) =>
53- patterns . some ( pattern => pattern . test ( window ) ) ) ?. filename ;
54- line . diagnostic ||= diagnostic ( window ) ;
55- line . infrastructure ||= infrastructure ( window ) ;
56- line . cpp ||= / \[ { 2 } F A I L E D { 2 } \] / . test ( window ) ;
57- line . todo ||= window . includes ( '# TODO :' ) ;
58- line . tapEnd ||= / { 2 } \. \. \. \n $ / . test ( window ) ;
59- line . gitStart ||= window . includes ( 'Changes not staged for commit:' ) ||
60- window . includes ( 'error: Your local changes to the following files' ) ;
61- line . gitEnd ||= window . includes ( 'no changes added to commit' ) ||
62- window . includes ( 'Failed to merge in the changes.' ) ;
63- this . continuation = window . length > this . windowSize ;
64- this . tail = window . slice ( - this . windowSize ) ;
65- if ( ! end ) return ;
37+ if ( afterBackslash && raw . startsWith ( '\\' ) ) text = text . slice ( 1 ) ;
38+ if ( raw ) afterBackslash = raw . endsWith ( '\\' ) ;
39+ for ( let offset = 0 ; offset < text . length ; ) {
40+ const newline = text . indexOf ( '\n' , offset ) ;
41+ const end = Math . min ( offset + 8192 , newline < 0 ? text . length : newline + 1 ) ;
42+ const window = tail + text . slice ( offset , end ) ;
43+ const lineEnd = end === newline + 1 ;
44+ yield { text : window , lineStart, lineEnd } ;
45+ if ( lineEnd ) {
46+ tail = '' ;
47+ lineStart = true ;
48+ } else {
49+ lineStart &&= window . length <= overlap ;
50+ tail = window . slice ( - overlap ) ;
51+ }
52+ offset = end ;
53+ }
54+ }
55+
56+ for await ( const chunk of source ) yield * consume ( decoder . write ( chunk ) ) ;
57+ yield * consume ( decoder . end ( ) ) ;
58+ if ( tail ) yield { text : `${ tail } \n` , lineStart, lineEnd : true } ;
59+ }
6660
67- this . tail = '' ;
68- this . continuation = false ;
69- this . backslash = false ;
70- this . line = { } ;
71- return this . finishLine ( line ) ;
61+ // Each line is reduced to a filename and failure markers. Neither a giant line
62+ // nor a giant TAP block needs to survive in memory.
63+ async function * failureLines ( windows , matchFile ) {
64+ let line = { } ;
65+ for await ( const { text, lineStart, lineEnd } of windows ) {
66+ line . file ??= matchFile ( text , lineStart ) ;
67+ line . diagnostic ||= diagnostic ( text ) ;
68+ line . infrastructure ||= infrastructure ( text ) ;
69+ line . cpp ||= / \[ { 2 } F A I L E D { 2 } \] / . test ( text ) ;
70+ line . tapStart ||= lineStart && / ^ n o t o k \d + / . test ( text ) ;
71+ line . tapEnd ||= lineEnd && / { 2 } \. \. \. \n $ / . test ( text ) ;
72+ line . todo ||= text . includes ( '# TODO :' ) ;
73+ line . gitStart ||= text . includes ( 'Changes not staged for commit:' ) ||
74+ text . includes ( 'error: Your local changes to the following files' ) ;
75+ line . gitEnd ||= text . includes ( 'no changes added to commit' ) ||
76+ text . includes ( 'Failed to merge in the changes.' ) ;
77+ if ( lineEnd ) {
78+ yield line ;
79+ line = { } ;
80+ }
7281 }
82+ }
83+
84+ async function findFailure ( lines ) {
85+ let history = [ ] ;
86+ let followingLines = 0 ;
87+ let tap = null ;
88+ let git = null ;
7389
74- finishLine ( line ) {
75- if ( / ^ n o t o k \d + / . test ( line . prefix ) ) {
76- this . tap = { } ;
77- this . context = 0 ;
90+ for await ( const line of lines ) {
91+ const precedingLines = history ;
92+ history = [ ...history , line . file ] . slice ( - 5 ) ;
93+ if ( line . tapStart ) {
94+ tap = { } ;
95+ followingLines = 0 ;
7896 }
79- if ( this . tap ) {
80- this . tap . match ??= line . match ;
81- this . tap . todo ||= line . todo ;
82- // Wait for the block to finish: a later TODO marks an expected failure.
97+ if ( tap ) {
98+ tap . file ??= line . file ;
99+ tap . todo ||= line . todo ;
100+ // A later TODO can mark this as an expected failure; wait for the ending .
83101 if ( line . tapEnd ) {
84- const match = ! this . tap . todo && this . tap . match ;
85- this . tap = null ;
86- if ( match ) return match ;
102+ if ( ! tap . todo && tap . file ) return tap . file ;
103+ tap = null ;
87104 }
88- } else {
89- if ( line . gitStart ) this . git = { } ;
90- if ( this . git ) {
91- this . git . match ??= line . match ;
92- if ( line . gitEnd ) {
93- const { match } = this . git ;
94- this . git = null ;
95- if ( match ) return match ;
96- }
97- }
98- if ( line . infrastructure || line . cpp ) {
99- const before = line . cpp ? 5 : 1 ;
100- const match = line . match || this . history . slice ( - before ) . find ( Boolean ) ;
101- if ( match ) return match ;
102- }
103- if ( line . diagnostic ) this . context = 6 ;
104- if ( this . context > 0 ) {
105- this . context -- ;
106- if ( line . match ) return line . match ;
105+ continue ;
106+ }
107+
108+ if ( line . gitStart ) git = { } ;
109+ if ( git ) {
110+ git . file ??= line . file ;
111+ if ( line . gitEnd ) {
112+ if ( git . file ) return git . file ;
113+ git = null ;
107114 }
108115 }
109- this . history . push ( line . match ) ;
110- if ( this . history . length > 5 ) this . history . shift ( ) ;
116+ if ( line . infrastructure || line . cpp ) {
117+ const before = line . cpp ? 5 : 1 ;
118+ const file = line . file || precedingLines . slice ( - before ) . find ( Boolean ) ;
119+ if ( file ) return file ;
120+ }
121+ if ( line . diagnostic ) followingLines = 6 ;
122+ if ( followingLines > 0 ) {
123+ followingLines -- ;
124+ if ( line . file ) return line . file ;
125+ }
111126 }
127+ }
112128
113- async scan ( source ) {
114- const decoder = new StringDecoder ( 'utf8' ) ;
115- const consume = ( text ) => {
116- let offset = 0 ;
117- while ( offset < text . length ) {
118- const newline = text . indexOf ( '\n' , offset ) ;
119- // Split even a giant single line into bounded pieces. Only the small
120- // overlap window and per-line flags survive between pieces.
121- const end = Math . min ( offset + 8192 , newline < 0 ? text . length : newline ) ;
122- const complete = end === newline ;
123- const match = this . fragment ( text . slice ( offset , end ) . replace ( / \r / g, '' ) , complete ) ;
124- if ( match ) return match ;
125- offset = end + ( complete ? 1 : 0 ) ;
126- }
129+ export class FailureFileScanner {
130+ constructor ( filenames ) {
131+ const aliases = [ ...filenames ] . map ( filename => ( { filename, paths : fileAliases ( filename ) } ) ) ;
132+ this . overlap = Math . max ( 256 ,
133+ ...aliases . flatMap ( ( { paths } ) => paths . map ( path => path . length + 2 ) ) ) ;
134+ const matchers = aliases . map ( ( { filename, paths } ) => ( {
135+ filename,
136+ patterns : paths . map ( path => {
137+ const escaped = path . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
138+ // Both delimiters must be real: a window boundary is not a path boundary.
139+ return new RegExp ( `[^\\w.-]${ escaped } (?=[^\\w./-])` ) ;
140+ } )
141+ } ) ) ;
142+ this . matchFile = ( text , lineStart ) => {
143+ // The start of a real log line is also a valid left delimiter.
144+ if ( lineStart ) text = `\n${ text } ` ;
145+ return matchers . find ( ( { patterns } ) =>
146+ patterns . some ( pattern => pattern . test ( text ) ) ) ?. filename ;
127147 } ;
128- for await ( const chunk of source ) {
129- const match = consume ( decoder . write ( chunk ) ) ;
130- if ( match ) return match ;
131- }
132- const match = consume ( decoder . end ( ) ) ;
133- return match || this . fragment ( '' , true ) ;
148+ }
149+
150+ scan ( source ) {
151+ return findFailure ( failureLines ( logWindows ( source , this . overlap ) , this . matchFile ) ) ;
134152 }
135153}
0 commit comments