flake-ledger 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/scan.mjs ADDED
@@ -0,0 +1,537 @@
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseJUnit } from './junit.mjs';
4
+
5
+ const SKIPPED_DIRECTORIES = new Set(['.git', '.hg', '.svn', 'node_modules']);
6
+ const JUNIT_EXTENSIONS = ['.xml', '.junit', '.xunit'];
7
+
8
+ export class ScanError extends Error {
9
+ constructor(message, options = {}) {
10
+ super(message);
11
+ this.name = 'ScanError';
12
+ this.path = options.path || null;
13
+ }
14
+ }
15
+
16
+ function isJUnitFile(name) {
17
+ const lower = name.toLowerCase();
18
+ return JUNIT_EXTENSIONS.some((extension) => lower.endsWith(extension));
19
+ }
20
+
21
+ function naturalCompare(left, right) {
22
+ return String(left).localeCompare(String(right), 'en', { numeric: true, sensitivity: 'base' });
23
+ }
24
+
25
+ /**
26
+ * Recursively find JUnit XML files under runsDir.
27
+ *
28
+ * VCS metadata and node_modules are skipped, symlinked directories are
29
+ * followed once (so a loop cannot hang the scan), and unreadable entries
30
+ * become warnings instead of aborting the whole ledger.
31
+ */
32
+ export function discoverJUnitFiles(runsDir, options = {}) {
33
+ const root = path.resolve(runsDir);
34
+ const warnings = options.warnings || [];
35
+ const files = [];
36
+ const visitedDirectories = new Set();
37
+
38
+ let rootStat;
39
+ try {
40
+ rootStat = statSync(root);
41
+ } catch (error) {
42
+ throw new ScanError(`cannot read runs directory ${root}: ${error.message}`, { path: root });
43
+ }
44
+ if (!rootStat.isDirectory()) {
45
+ throw new ScanError(`runs path is not a directory: ${root}`, { path: root });
46
+ }
47
+
48
+ const walk = (directory) => {
49
+ let realDirectory;
50
+ try {
51
+ realDirectory = realpathSync(directory);
52
+ } catch (error) {
53
+ warnings.push({ file: directory, message: `cannot resolve directory: ${error.message}` });
54
+ return;
55
+ }
56
+ if (visitedDirectories.has(realDirectory)) return;
57
+ visitedDirectories.add(realDirectory);
58
+
59
+ let entries;
60
+ try {
61
+ entries = readdirSync(directory, { withFileTypes: true });
62
+ } catch (error) {
63
+ warnings.push({ file: directory, message: `cannot read directory: ${error.message}` });
64
+ return;
65
+ }
66
+ entries.sort((left, right) => naturalCompare(left.name, right.name));
67
+
68
+ for (const entry of entries) {
69
+ const fullPath = path.join(directory, entry.name);
70
+ if (entry.isDirectory()) {
71
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
72
+ walk(fullPath);
73
+ continue;
74
+ }
75
+ if (entry.isSymbolicLink()) {
76
+ let target;
77
+ try {
78
+ target = statSync(fullPath);
79
+ } catch (error) {
80
+ warnings.push({ file: fullPath, message: `cannot follow symlink: ${error.message}` });
81
+ continue;
82
+ }
83
+ if (target.isDirectory()) {
84
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(fullPath);
85
+ } else if (target.isFile() && isJUnitFile(entry.name)) {
86
+ files.push(fullPath);
87
+ }
88
+ continue;
89
+ }
90
+ if (entry.isFile() && isJUnitFile(entry.name)) files.push(fullPath);
91
+ }
92
+ };
93
+
94
+ walk(root);
95
+ files.sort((left, right) => naturalCompare(path.relative(root, left), path.relative(root, right)));
96
+ return files;
97
+ }
98
+
99
+ /**
100
+ * Recognize the common shard markers in a file or directory stem:
101
+ * shard-1, part-2, run-3-worker-4, or report-1-of-2. Returns the run prefix
102
+ * before the marker (possibly the empty string) or null when there is none.
103
+ */
104
+ function shardPrefixForStem(stem) {
105
+ const shardMatch = stem.match(
106
+ /^(?:(.*?)[-_.])?(shard|part|chunk|split|worker|matrix|node)(?:[-_.]?\d+)?(?:[-_.]of[-_.]?\d+)?$/i,
107
+ );
108
+ if (shardMatch) return (shardMatch[1] || '').replace(/[-_.]+$/, '');
109
+ const ofMatch = stem.match(/^(?:(.*?)[-_.])?\d+[-_.]of[-_.]?\d+$/i);
110
+ if (ofMatch) return (ofMatch[1] || '').replace(/[-_.]+$/, '');
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Recognize the flat layout runs/<run-id>-shard-1.xml or runs/shard-1.xml.
116
+ * Files whose names carry a shard marker belong to one run; ordinary files
117
+ * such as run-1.xml remain separate runs.
118
+ */
119
+ function rootShardIdentity(relativePath) {
120
+ if (relativePath.includes(path.sep)) return null;
121
+ const stem = path.basename(relativePath, path.extname(relativePath));
122
+ const prefix = shardPrefixForStem(stem);
123
+ if (prefix === null) return null;
124
+ return { id: prefix || 'root', key: prefix.toLowerCase() || 'root' };
125
+ }
126
+
127
+ /**
128
+ * Recognize a top-level run directory named run-1-shard-2 or shard-2. These
129
+ * are artifacts from one CI run split across directories rather than files.
130
+ */
131
+ function directoryShardIdentity(directoryName) {
132
+ const prefix = shardPrefixForStem(directoryName);
133
+ if (prefix === null) return null;
134
+ return { id: prefix || 'root', key: prefix.toLowerCase() || 'root' };
135
+ }
136
+
137
+ /**
138
+ * Decide which XML files belong to the same CI run.
139
+ *
140
+ * The conventional layout is runs/<run-id>/<shard>.xml, so by default the
141
+ * first directory below <runs-dir> is the run and every XML inside it is a
142
+ * shard of that run. XML files placed directly in <runs-dir> are separate
143
+ * runs, except when their names carry an explicit shard marker such as
144
+ * shard-1.xml or run-3-part-2.xml. Top-level directories with the same shard
145
+ * marker (run-3-shard-1/, run-3-shard-2/) are merged the same way.
146
+ *
147
+ * groupBy "parent" groups by immediate parent directory; "file" makes every
148
+ * XML document its own run.
149
+ */
150
+ export function groupFilesByRun(runsDir, files, options = {}) {
151
+ const root = path.resolve(runsDir);
152
+ const groupBy = options.groupBy || 'auto';
153
+ const groups = new Map();
154
+
155
+ for (const file of files) {
156
+ const relative = path.relative(root, file);
157
+ const parts = relative.split(path.sep);
158
+ let key;
159
+ let id;
160
+ let runPath;
161
+
162
+ const shardIdentity =
163
+ groupBy === 'auto' && parts.length === 1 ? rootShardIdentity(relative) : null;
164
+ const directoryShard =
165
+ groupBy === 'auto' && parts.length > 1 ? directoryShardIdentity(parts[0]) : null;
166
+
167
+ if (groupBy === 'file') {
168
+ key = `file:${relative}`;
169
+ id = path.basename(relative, path.extname(relative));
170
+ runPath = file;
171
+ } else if (shardIdentity) {
172
+ key = `shards:${shardIdentity.key}`;
173
+ id = shardIdentity.id;
174
+ runPath = root;
175
+ } else if (directoryShard) {
176
+ key = `shards:${directoryShard.key}`;
177
+ id = directoryShard.id;
178
+ runPath = root;
179
+ } else if (parts.length === 1) {
180
+ key = `file:${relative}`;
181
+ id = path.basename(relative, path.extname(relative));
182
+ runPath = file;
183
+ } else if (groupBy === 'parent') {
184
+ const parent = parts.slice(0, -1).join(path.sep);
185
+ key = `parent:${parent}`;
186
+ id = parent;
187
+ runPath = path.join(root, parent);
188
+ } else {
189
+ key = `dir:${parts[0]}`;
190
+ id = parts[0];
191
+ runPath = path.join(root, parts[0]);
192
+ }
193
+
194
+ if (!groups.has(key)) {
195
+ groups.set(key, { id, path: runPath, files: [] });
196
+ }
197
+ groups.get(key).files.push(file);
198
+ }
199
+
200
+ return [...groups.values()].sort((left, right) => {
201
+ const idOrder = naturalCompare(left.id, right.id);
202
+ return idOrder !== 0 ? idOrder : naturalCompare(left.path, right.path);
203
+ });
204
+ }
205
+
206
+ function statusesOf(attempts) {
207
+ return attempts.map((attempt) => attempt.status);
208
+ }
209
+
210
+ function attemptGroupKey(attempt) {
211
+ // Newer scanners attach suiteKey, which distinguishes separate <testsuite>
212
+ // elements (Playwright projects) from repeated testcases in one suite.
213
+ // Fall back to the source file so older callers with plain attempt objects
214
+ // still merge duplicates from the same report.
215
+ return attempt.suiteKey || attempt.source || '';
216
+ }
217
+
218
+ function hasRepeatedAttemptInSameSuite(attempts) {
219
+ const seen = new Set();
220
+ for (const attempt of attempts) {
221
+ const key = attemptGroupKey(attempt);
222
+ if (seen.has(key)) return true;
223
+ seen.add(key);
224
+ }
225
+ return false;
226
+ }
227
+
228
+ function outcomeForAttempts(attempts) {
229
+ const statuses = statusesOf(attempts);
230
+ const failures = statuses.filter((status) => status === 'failed' || status === 'error');
231
+ const passes = statuses.filter((status) => status === 'passed');
232
+
233
+ if (failures.length > 0 && passes.length > 0) {
234
+ const final = statuses[statuses.length - 1];
235
+ if (final === 'passed') return 'passed-on-retry';
236
+ return 'failed-on-retry';
237
+ }
238
+ if (failures.length > 0) return failures[failures.length - 1];
239
+ if (attempts.some((attempt) => attempt.retried)) return 'passed-on-retry';
240
+ if (passes.length > 0) {
241
+ // pytest-rerunfailures writes one <testcase> per attempt but does not put
242
+ // the earlier failure in the XML: a recovered test appears as the same
243
+ // passing testcase twice. Repeated passes in one suite are therefore retry
244
+ // evidence. The same test in two different Playwright project suites is a
245
+ // duplicate execution, not a retry, so only repeats within one suite count.
246
+ if (hasRepeatedAttemptInSameSuite(attempts)) return 'passed-on-retry';
247
+ return 'passed';
248
+ }
249
+ return statuses.length > 0 ? 'skipped' : 'unknown';
250
+ }
251
+
252
+ function finalStatusForOutcome(outcome) {
253
+ if (outcome === 'passed' || outcome === 'passed-on-retry') return 'passed';
254
+ if (outcome === 'error') return 'error';
255
+ if (outcome === 'skipped' || outcome === 'unknown') return outcome;
256
+ return 'failed';
257
+ }
258
+
259
+ function wilsonLowerBound(successes, trials, z = 1.96) {
260
+ if (!Number.isFinite(trials) || trials <= 0) return 0;
261
+ const proportion = Math.max(0, Math.min(1, successes / trials));
262
+ const zSquared = z * z;
263
+ const denominator = 1 + zSquared / trials;
264
+ const centre = proportion + zSquared / (2 * trials);
265
+ const margin = z * Math.sqrt((proportion * (1 - proportion) + zSquared / (4 * trials)) / trials);
266
+ return Math.max(0, (centre - margin) / denominator);
267
+ }
268
+
269
+ function classificationFor(counts) {
270
+ const failLikeRuns = counts.failedRuns + counts.errorRuns;
271
+ if (failLikeRuns > 0 && counts.passedRuns > 0) return 'cross-run flake';
272
+ if (counts.passedOnRetryRuns > 0) return 'passed-on-retry';
273
+ if (counts.failedOnRetryRuns > 0) return 'failed-on-retry';
274
+ if (failLikeRuns > 0) return 'always-failing';
275
+ if (counts.passedRuns > 0) return 'stable';
276
+ if (counts.skippedRuns > 0) return 'skipped';
277
+ return 'unknown';
278
+ }
279
+
280
+ function compareRuns(left, right) {
281
+ const leftTime = left.startedAtMs;
282
+ const rightTime = right.startedAtMs;
283
+ if (leftTime !== null && rightTime !== null && leftTime !== rightTime) return leftTime - rightTime;
284
+ const idOrder = naturalCompare(left.id, right.id);
285
+ return idOrder !== 0 ? idOrder : naturalCompare(left.path, right.path);
286
+ }
287
+
288
+ function compareTests(left, right) {
289
+ if (right.score !== left.score) return right.score - left.score;
290
+ if (right.unstableRate !== left.unstableRate) return right.unstableRate - left.unstableRate;
291
+ const failOrder = (right.failedRuns + right.errorRuns) - (left.failedRuns + left.errorRuns);
292
+ if (failOrder !== 0) return failOrder;
293
+ if (right.runs !== left.runs) return right.runs - left.runs;
294
+ return naturalCompare(left.id, right.id);
295
+ }
296
+
297
+ /**
298
+ * Scan a directory of JUnit XML and collapse it into one ledger entry per
299
+ * classname::name. Every attempt stays in the entry's timeline, so a failure
300
+ * followed by a retry pass remains visible instead of disappearing into a
301
+ * green run.
302
+ */
303
+ export function scanRuns(runsDir, options = {}) {
304
+ const root = path.resolve(runsDir);
305
+ const warnings = [];
306
+ const files = discoverJUnitFiles(root, { warnings });
307
+ if (files.length === 0) {
308
+ return {
309
+ runsDir: root,
310
+ files: [],
311
+ runs: [],
312
+ tests: [],
313
+ warnings,
314
+ totals: { files: 0, runs: 0, tests: 0, attempts: 0 },
315
+ };
316
+ }
317
+
318
+ const groups = groupFilesByRun(root, files, options);
319
+ const runs = [];
320
+
321
+ for (const group of groups) {
322
+ const run = {
323
+ id: group.id,
324
+ path: group.path,
325
+ files: [],
326
+ startedAt: null,
327
+ startedAtMs: null,
328
+ testCount: 0,
329
+ attemptCount: 0,
330
+ warnings: [],
331
+ };
332
+ const parsedFiles = [];
333
+
334
+ for (const file of group.files) {
335
+ let contents;
336
+ try {
337
+ contents = readFileSync(file, 'utf8');
338
+ } catch (error) {
339
+ const warning = { file, runId: group.id, message: `cannot read file: ${error.message}` };
340
+ warnings.push(warning);
341
+ run.warnings.push(warning);
342
+ continue;
343
+ }
344
+
345
+ try {
346
+ const parsed = parseJUnit(contents, file);
347
+ parsedFiles.push(parsed);
348
+ run.files.push(file);
349
+ if (
350
+ parsed.timestampMs !== null &&
351
+ (run.startedAtMs === null || parsed.timestampMs < run.startedAtMs)
352
+ ) {
353
+ run.startedAtMs = parsed.timestampMs;
354
+ run.startedAt = parsed.timestamp;
355
+ }
356
+ } catch (error) {
357
+ const warning = {
358
+ file,
359
+ runId: group.id,
360
+ message: `cannot parse JUnit XML: ${error.message}`,
361
+ };
362
+ warnings.push(warning);
363
+ run.warnings.push(warning);
364
+ }
365
+ }
366
+
367
+ parsedFiles.sort((left, right) => {
368
+ const leftTime = left.timestampMs;
369
+ const rightTime = right.timestampMs;
370
+ if (leftTime !== null && rightTime !== null && leftTime !== rightTime) return leftTime - rightTime;
371
+ // Shards are commonly named shard-1.xml, shard-2.xml. Compare basenames
372
+ // naturally before the full relative path so a nested shard-2 does not
373
+ // sort before the top-level shard-1.
374
+ const baseOrder = naturalCompare(
375
+ path.basename(left.sourcePath),
376
+ path.basename(right.sourcePath),
377
+ );
378
+ if (baseOrder !== 0) return baseOrder;
379
+ return naturalCompare(
380
+ path.relative(root, left.sourcePath),
381
+ path.relative(root, right.sourcePath),
382
+ );
383
+ });
384
+
385
+ const testsById = new Map();
386
+ for (const parsed of parsedFiles) {
387
+ for (const attempt of parsed.attempts) {
388
+ let test = testsById.get(attempt.id);
389
+ if (!test) {
390
+ test = {
391
+ id: attempt.id,
392
+ classname: attempt.classname,
393
+ name: attempt.name,
394
+ attempts: [],
395
+ };
396
+ testsById.set(attempt.id, test);
397
+ }
398
+ test.attempts.push(attempt);
399
+ run.attemptCount += 1;
400
+ }
401
+ run.testCount += parsed.testcaseCount;
402
+ }
403
+
404
+ const runTests = [];
405
+ for (const test of testsById.values()) {
406
+ const outcome = outcomeForAttempts(test.attempts);
407
+ runTests.push({
408
+ id: test.id,
409
+ classname: test.classname,
410
+ name: test.name,
411
+ outcome,
412
+ status: finalStatusForOutcome(outcome),
413
+ attemptCount: test.attempts.length,
414
+ attempts: test.attempts,
415
+ });
416
+ }
417
+ run.tests = runTests;
418
+ run.testCount = runTests.length;
419
+ runs.push(run);
420
+ }
421
+
422
+ runs.sort(compareRuns);
423
+
424
+ const ledger = new Map();
425
+ for (const run of runs) {
426
+ for (const test of run.tests) {
427
+ let record = ledger.get(test.id);
428
+ if (!record) {
429
+ record = {
430
+ id: test.id,
431
+ classname: test.classname,
432
+ name: test.name,
433
+ runs: 0,
434
+ attempts: 0,
435
+ passedAttempts: 0,
436
+ failedAttempts: 0,
437
+ errorAttempts: 0,
438
+ skippedAttempts: 0,
439
+ passedRuns: 0,
440
+ failedRuns: 0,
441
+ errorRuns: 0,
442
+ skippedRuns: 0,
443
+ passedOnRetryRuns: 0,
444
+ failedOnRetryRuns: 0,
445
+ unstableRuns: 0,
446
+ eligibleRuns: 0,
447
+ unstableRate: 0,
448
+ score: 0,
449
+ classification: 'unknown',
450
+ timeline: [],
451
+ };
452
+ ledger.set(test.id, record);
453
+ }
454
+
455
+ record.runs += 1;
456
+ record.attempts += test.attempts.length;
457
+ for (const attempt of test.attempts) {
458
+ if (attempt.status === 'passed') record.passedAttempts += 1;
459
+ else if (attempt.status === 'failed') record.failedAttempts += 1;
460
+ else if (attempt.status === 'error') record.errorAttempts += 1;
461
+ else if (attempt.status === 'skipped') record.skippedAttempts += 1;
462
+ }
463
+
464
+ if (test.outcome === 'passed') {
465
+ record.passedRuns += 1;
466
+ record.eligibleRuns += 1;
467
+ } else if (test.outcome === 'passed-on-retry') {
468
+ record.passedRuns += 1;
469
+ record.passedOnRetryRuns += 1;
470
+ record.unstableRuns += 1;
471
+ record.eligibleRuns += 1;
472
+ } else if (test.outcome === 'failed-on-retry') {
473
+ record.failedRuns += 1;
474
+ record.failedOnRetryRuns += 1;
475
+ record.unstableRuns += 1;
476
+ record.eligibleRuns += 1;
477
+ } else if (test.outcome === 'failed') {
478
+ record.failedRuns += 1;
479
+ record.unstableRuns += 1;
480
+ record.eligibleRuns += 1;
481
+ } else if (test.outcome === 'error') {
482
+ record.errorRuns += 1;
483
+ record.unstableRuns += 1;
484
+ record.eligibleRuns += 1;
485
+ } else if (test.outcome === 'skipped') {
486
+ record.skippedRuns += 1;
487
+ }
488
+
489
+ record.timeline.push({
490
+ runId: run.id,
491
+ runPath: run.path,
492
+ id: test.id,
493
+ classname: test.classname,
494
+ name: test.name,
495
+ outcome: test.outcome,
496
+ status: test.status,
497
+ attemptCount: test.attemptCount,
498
+ attempts: test.attempts,
499
+ });
500
+ }
501
+ }
502
+
503
+ const tests = [...ledger.values()];
504
+ for (const test of tests) {
505
+ test.unstableRate = test.eligibleRuns > 0 ? test.unstableRuns / test.eligibleRuns : 0;
506
+ test.score = wilsonLowerBound(test.unstableRuns, test.eligibleRuns);
507
+ test.classification = classificationFor(test);
508
+ test.runIds = test.timeline.map((entry) => entry.runId);
509
+ }
510
+ tests.sort(compareTests);
511
+
512
+ for (const run of runs) {
513
+ delete run.tests;
514
+ delete run.warnings;
515
+ }
516
+
517
+ return {
518
+ runsDir: root,
519
+ files,
520
+ runs,
521
+ tests,
522
+ warnings,
523
+ totals: {
524
+ files: files.length,
525
+ runs: runs.length,
526
+ tests: tests.length,
527
+ attempts: runs.reduce((sum, run) => sum + run.attemptCount, 0),
528
+ },
529
+ };
530
+ }
531
+
532
+ export const internals = {
533
+ outcomeForAttempts,
534
+ classificationFor,
535
+ wilsonLowerBound,
536
+ compareTests,
537
+ };