Skip to content

Commit 739ce74

Browse files
committed
fixup! feat: add ncu-ci resume command
1 parent f3578de commit 739ce74

2 files changed

Lines changed: 132 additions & 106 deletions

File tree

‎lib/ci/failure_file_scanner.js‎

Lines changed: 124 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import { StringDecoder } from 'node:string_decoder';
22

33
import { 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.
75
function 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(/[gy]/g, '')));
109
return text => matchers.some(pattern => pattern.test(text));
@@ -13,123 +12,142 @@ function createMatcher(patterns) {
1312
const diagnostic = createMatcher(FAILURE_PATTERNS.diagnostic);
1413
const 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(/\.(?:js|mjs|cjs|out)$/, ''));
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(/\.(?:js|mjs|cjs|out)$/, ''));
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}FAILED {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}FAILED {2}\]/.test(text);
70+
line.tapStart ||= lineStart && /^not ok \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 (/^not ok \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
}

‎test/unit/ci_failure_file_scanner.test.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ describe('Streaming failure file scanner', () => {
9191
assert.equal(await scan('not ok 1 parallel/test-example\n partial output'), undefined);
9292
});
9393

94+
it('does not carry an unfinished failure into another log when reused', async() => {
95+
const scanner = new FailureFileScanner([filename]);
96+
assert.equal(await scanner.scan([Buffer.from('not ok 1 parallel/test-example\n')]),
97+
undefined);
98+
assert.equal(await scanner.scan([Buffer.from('unrelated output\n ...\n')]), undefined);
99+
assert.equal(await scanner.scan([Buffer.from(tap(' actual failure'))]), filename);
100+
});
101+
94102
it('matches a compiler diagnostic without a final newline', async() => {
95103
assert.equal(await scan('../src/node.cc:42: error: failure', ['src/node.cc'], 1),
96104
'src/node.cc');

0 commit comments

Comments
 (0)