@boyingliu01/xp-gate 0.35.3 → 0.35.5
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/gates/test-layers.ts +149 -20
- package/lib/__tests__/flaky.test.js +128 -0
- package/lib/doctor.js +56 -0
- package/lib/flaky.js +208 -0
- package/lib/gate-audit.ts +15 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +26 -0
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/lib/delphi-run.d.ts +0 -1
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -1
- package/plugins/dsh/lib/delphi-run.js +55 -5
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/src/delphi-findings.test.ts +109 -0
- package/plugins/dsh/src/delphi-run.ts +53 -6
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
package/gates/test-layers.ts
CHANGED
|
@@ -20,8 +20,23 @@ export interface TestLayerReport {
|
|
|
20
20
|
layer1: {
|
|
21
21
|
pairedSources: number;
|
|
22
22
|
unpairedSources: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Sources the pairing convention does not describe (#642).
|
|
25
|
+
*
|
|
26
|
+
* Reported apart from `unpairedSources` because the two call for opposite
|
|
27
|
+
* responses: an unpaired source needs a test, whereas an out-of-scope source
|
|
28
|
+
* (a shell script covered by BATS) needs the convention widened. Counting it
|
|
29
|
+
* as "unpaired" would ask the reader to write a .test.ts for `pre-commit`.
|
|
30
|
+
*/
|
|
31
|
+
outOfScopeSources: string[];
|
|
23
32
|
pairRate: number;
|
|
24
33
|
};
|
|
34
|
+
/** Test suites that live outside src/ -- BATS and other shell-level suites (#642). */
|
|
35
|
+
shellSuites: {
|
|
36
|
+
total: number;
|
|
37
|
+
bats: number;
|
|
38
|
+
files: string[];
|
|
39
|
+
};
|
|
25
40
|
layer2: {
|
|
26
41
|
integrationFiles: number;
|
|
27
42
|
};
|
|
@@ -175,6 +190,8 @@ interface LayerCounts {
|
|
|
175
190
|
integrationTests: string[];
|
|
176
191
|
e2eTests: string[];
|
|
177
192
|
unpairedSources: string[];
|
|
193
|
+
outOfScopeSources: string[];
|
|
194
|
+
shellSuites: string[];
|
|
178
195
|
pairedCount: number;
|
|
179
196
|
}
|
|
180
197
|
|
|
@@ -189,15 +206,24 @@ function appendPairingLines(messages: string[], counts: LayerCounts, totalNeedin
|
|
|
189
206
|
messages.push(' Layer 1 (Unit Pairing):');
|
|
190
207
|
messages.push(` Paired: ${counts.pairedCount}/${totalNeedingTests} (${pairRate}%)`);
|
|
191
208
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
209
|
+
appendUnpairedLines(messages, counts.unpairedSources);
|
|
210
|
+
|
|
211
|
+
// Out-of-scope sources get their own line: folding them into the unpaired list
|
|
212
|
+
// would ask the reader to write a module test for a shell script (#642).
|
|
213
|
+
if (counts.outOfScopeSources.length > 0) {
|
|
214
|
+
messages.push(` Not pair-able (shell/other): ${counts.outOfScopeSources.length}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Unpaired list, kept short enough not to bury the summary above it (#620). */
|
|
219
|
+
function appendUnpairedLines(messages: string[], unpaired: string[]): void {
|
|
220
|
+
if (unpaired.length === 0) return;
|
|
221
|
+
if (unpaired.length <= UNPAIRED_DISPLAY_LIMIT) {
|
|
222
|
+
messages.push(` Unpaired: ${unpaired.join(', ')}`);
|
|
198
223
|
return;
|
|
199
224
|
}
|
|
200
|
-
messages.push(` Unpaired: ${unpaired.
|
|
225
|
+
messages.push(` Unpaired: ${unpaired.length} files (showing first 10)`);
|
|
226
|
+
unpaired.slice(0, UNPAIRED_DISPLAY_LIMIT).forEach(f => messages.push(` - ${f}`));
|
|
201
227
|
}
|
|
202
228
|
|
|
203
229
|
/** Format the console block for one analysis. */
|
|
@@ -207,10 +233,14 @@ function buildLayerMessages(counts: LayerCounts, totalNeedingTests: number): str
|
|
|
207
233
|
'━━━ Test Layer Analytics ━━━',
|
|
208
234
|
'',
|
|
209
235
|
` Sources: ${counts.sourceFiles.length} files`,
|
|
210
|
-
` Tests: ${counts.testFiles.length} total`,
|
|
236
|
+
` Tests: ${counts.testFiles.length + counts.shellSuites.length} total`,
|
|
211
237
|
` Unit: ${counts.unitTests.length}`,
|
|
212
|
-
` Integra: ${counts.integrationTests.length}`,
|
|
238
|
+
` Integra: ${counts.integrationTests.length + counts.shellSuites.length}`,
|
|
213
239
|
` E2E: ${counts.e2eTests.length}`,
|
|
240
|
+
// Printed separately rather than folded into "Integra": whether a suite is
|
|
241
|
+
// BATS or a TS integration test is a fact about the repo that the reader is
|
|
242
|
+
// here to learn, and a shell suite is never counted as a unit test (#642).
|
|
243
|
+
` Shell: ${counts.shellSuites.length}`,
|
|
214
244
|
'',
|
|
215
245
|
];
|
|
216
246
|
|
|
@@ -236,13 +266,21 @@ function buildLayerMessages(counts: LayerCounts, totalNeedingTests: number): str
|
|
|
236
266
|
function computePairing(
|
|
237
267
|
sourceFiles: string[],
|
|
238
268
|
allFiles: string[],
|
|
239
|
-
): { unpairedSources: string[]; pairedCount: number } {
|
|
269
|
+
): { unpairedSources: string[]; outOfScopeSources: string[]; pairedCount: number } {
|
|
240
270
|
const allSourceRelative = allFiles.map(f => path.join('src', f));
|
|
241
271
|
const unpairedSources: string[] = [];
|
|
272
|
+
const outOfScopeSources: string[] = [];
|
|
242
273
|
let pairedCount = 0;
|
|
243
274
|
|
|
244
275
|
for (const source of sourceFiles) {
|
|
245
276
|
if (isExempt(path.basename(source))) continue;
|
|
277
|
+
// A source the convention does not describe is not "unpaired" -- nobody was
|
|
278
|
+
// ever asked to pair it. Reporting it as unpaired would send the reader to
|
|
279
|
+
// write a module test for a shell script (#642).
|
|
280
|
+
if (isPairingOutOfScope(source)) {
|
|
281
|
+
outOfScopeSources.push(source);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
246
284
|
if (findExpectedTest(path.join('src', source), allSourceRelative)) {
|
|
247
285
|
pairedCount++;
|
|
248
286
|
} else {
|
|
@@ -250,7 +288,95 @@ function computePairing(
|
|
|
250
288
|
}
|
|
251
289
|
}
|
|
252
290
|
|
|
253
|
-
return { unpairedSources, pairedCount };
|
|
291
|
+
return { unpairedSources, outOfScopeSources, pairedCount };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Directories that hold test suites but are not under `src/` (#642).
|
|
296
|
+
*
|
|
297
|
+
* `test-layers` used to scan only `<repo>/src`, so this repository's 166 BATS
|
|
298
|
+
* suites were invisible: the report said "Integration: 0" about a repo whose
|
|
299
|
+
* hook tests spawn real git, real shell adapters and real processes. A metric
|
|
300
|
+
* that omits 37% of the suites cannot be used to judge the test strategy.
|
|
301
|
+
*/
|
|
302
|
+
const SHELL_SUITE_DIRS = [
|
|
303
|
+
'githooks/__tests__',
|
|
304
|
+
'scripts/__tests__',
|
|
305
|
+
'tests',
|
|
306
|
+
'test',
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
/** A shell-level test suite: BATS or a bare .test.cjs/.test.mjs. */
|
|
310
|
+
function isShellSuite(normalized: string): boolean {
|
|
311
|
+
return /\.test\.bats$/.test(normalized)
|
|
312
|
+
|| /\.test\.(cjs|mjs)$/.test(normalized)
|
|
313
|
+
|| /\/__tests__\/[^/]+\.bats$/.test(normalized);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Find test suites outside `src/` (#642).
|
|
318
|
+
*
|
|
319
|
+
* Bounded to the conventional test directories rather than walking the whole
|
|
320
|
+
* repo: node_modules and build output would otherwise dominate, and a suite that
|
|
321
|
+
* is not under one of these roots is not discoverable by the runner either.
|
|
322
|
+
*/
|
|
323
|
+
function listDirNames(abs: string): string[] {
|
|
324
|
+
try {
|
|
325
|
+
// Explicit encoding: without it readdirSync may hand back Buffer entries.
|
|
326
|
+
// The array check covers stubbed/mocked fs implementations too -- a suite
|
|
327
|
+
// that exercises the "directory missing" path must not crash the scanner.
|
|
328
|
+
const listing = fs.readdirSync(abs, { encoding: 'utf8' });
|
|
329
|
+
return Array.isArray(listing) ? listing : [];
|
|
330
|
+
} catch {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Suites under one shell-test root, as repo-relative forward-slash paths. */
|
|
336
|
+
function suitesInDir(root: string, rel: string): string[] {
|
|
337
|
+
const prefix = rel.split(path.sep).join('/');
|
|
338
|
+
return listDirNames(path.join(root, rel))
|
|
339
|
+
.map(name => `${prefix}/${name}`)
|
|
340
|
+
.filter(isShellSuite);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function discoverShellSuites(cwd?: string): string[] {
|
|
344
|
+
const root = cwd || process.cwd();
|
|
345
|
+
const found = SHELL_SUITE_DIRS.flatMap(rel => suitesInDir(root, rel));
|
|
346
|
+
// Sorted so the report is stable across filesystems with differing readdir order.
|
|
347
|
+
return found.sort();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Files the `.test.ts` pairing convention does not apply to (#642).
|
|
352
|
+
*
|
|
353
|
+
* Shell scripts are exercised by BATS suites, not by a neighbouring module
|
|
354
|
+
* test. Listing them separately keeps the pair rate about the language whose
|
|
355
|
+
* convention it actually describes.
|
|
356
|
+
*/
|
|
357
|
+
function isPairingOutOfScope(filePath: string): boolean {
|
|
358
|
+
return /\.(sh|bash|bats)$/.test(filePath) || !/\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt)$/.test(filePath);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The summary block; shell suites are added to the test total (#642). */
|
|
362
|
+
function buildSummary(counts: LayerCounts, shellOutsideSrc: string[]): TestLayerReport['summary'] {
|
|
363
|
+
return {
|
|
364
|
+
totalSources: counts.sourceFiles.length,
|
|
365
|
+
totalTests: counts.testFiles.length + shellOutsideSrc.length,
|
|
366
|
+
unitTests: counts.unitTests.length,
|
|
367
|
+
integrationTests: counts.integrationTests.length,
|
|
368
|
+
e2eTests: counts.e2eTests.length,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Shell-level suites that the src/ scan did not already pick up (#642).
|
|
374
|
+
*
|
|
375
|
+
* Keeping these out of the count made "Integration: 0" a statement about the
|
|
376
|
+
* scanner rather than about the repository.
|
|
377
|
+
*/
|
|
378
|
+
function shellSuitesOutside(testFiles: string[], dir: string): string[] {
|
|
379
|
+
return discoverShellSuites(dir).filter(f => !testFiles.includes(f));
|
|
254
380
|
}
|
|
255
381
|
|
|
256
382
|
/**
|
|
@@ -264,7 +390,8 @@ export function analyzeTestLayers(cwd?: string): TestLayerReport {
|
|
|
264
390
|
const testFiles = allFiles.filter(f => isTestFile(f));
|
|
265
391
|
const byLayer = (layer: string): string[] => testFiles.filter(f => classifyTestLayer(f) === layer);
|
|
266
392
|
|
|
267
|
-
const { unpairedSources, pairedCount } = computePairing(sourceFiles, allFiles);
|
|
393
|
+
const { unpairedSources, outOfScopeSources, pairedCount } = computePairing(sourceFiles, allFiles);
|
|
394
|
+
const shellOutsideSrc = shellSuitesOutside(testFiles, dir);
|
|
268
395
|
|
|
269
396
|
const counts: LayerCounts = {
|
|
270
397
|
sourceFiles,
|
|
@@ -273,23 +400,25 @@ export function analyzeTestLayers(cwd?: string): TestLayerReport {
|
|
|
273
400
|
integrationTests: byLayer('integration'),
|
|
274
401
|
e2eTests: byLayer('e2e'),
|
|
275
402
|
unpairedSources,
|
|
403
|
+
outOfScopeSources,
|
|
404
|
+
shellSuites: shellOutsideSrc,
|
|
276
405
|
pairedCount,
|
|
277
406
|
};
|
|
278
|
-
const totalNeedingTests = sourceFiles.filter(f => !isExempt(path.basename(f))).length;
|
|
407
|
+
const totalNeedingTests = sourceFiles.filter(f => !isExempt(path.basename(f)) && !isPairingOutOfScope(f)).length;
|
|
279
408
|
|
|
280
409
|
return {
|
|
281
|
-
summary:
|
|
282
|
-
totalSources: sourceFiles.length,
|
|
283
|
-
totalTests: testFiles.length,
|
|
284
|
-
unitTests: counts.unitTests.length,
|
|
285
|
-
integrationTests: counts.integrationTests.length,
|
|
286
|
-
e2eTests: counts.e2eTests.length,
|
|
287
|
-
},
|
|
410
|
+
summary: buildSummary(counts, shellOutsideSrc),
|
|
288
411
|
layer1: {
|
|
289
412
|
pairedSources: pairedCount,
|
|
290
413
|
unpairedSources,
|
|
414
|
+
outOfScopeSources,
|
|
291
415
|
pairRate: totalNeedingTests > 0 ? Math.round((pairedCount / totalNeedingTests) * 100) : 100,
|
|
292
416
|
},
|
|
417
|
+
shellSuites: {
|
|
418
|
+
total: shellOutsideSrc.length,
|
|
419
|
+
bats: shellOutsideSrc.filter(f => f.endsWith('.test.bats') || f.endsWith('.bats')).length,
|
|
420
|
+
files: shellOutsideSrc,
|
|
421
|
+
},
|
|
293
422
|
layer2: {
|
|
294
423
|
integrationFiles: counts.integrationTests.length,
|
|
295
424
|
},
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-603 Flaky 测试治理:首过率度量、重试上限、隔离台账
|
|
3
|
+
* @intent 指南 §11「Flaky 测试治理」整章空白:无重试上限、无首过率/重跑率度量、
|
|
4
|
+
* 无隔离台账与到期机制。`xp-gate retro` 只统计 rework rate 和 gate pass rate,
|
|
5
|
+
* 回答不了「我们的测试稳不稳」。
|
|
6
|
+
*
|
|
7
|
+
* 这个缺口有一个具体的代价:**不稳定的测试会被当成产品缺陷去修**。
|
|
8
|
+
* 一个失败-run 如果只是抖动,开发者花在"修"它上面的时间全是浪费;
|
|
9
|
+
* 而如果它是真的,把它当抖动重跑就是漏网。两条路都错在同一个地方——
|
|
10
|
+
* 我们没有任何数据区分这两者。
|
|
11
|
+
*
|
|
12
|
+
* 三个设计决定:
|
|
13
|
+
*
|
|
14
|
+
* 1. **重跑成功不计入首过率。** 这是整个度量的立身之本。如果 `passed: true`
|
|
15
|
+
* 不管是不是重跑得来的都算通过,首过率永远是 100%,度量就没有意义。
|
|
16
|
+
* 记录必须显式携带 `attempt`,否则"重跑后通过"和"一次通过"在数据上
|
|
17
|
+
* 无法区分——而这两者正是要区分的全部。
|
|
18
|
+
*
|
|
19
|
+
* 2. **重试必须有上限,且默认极小(1 次)。** 无上限重试等于把门禁变成
|
|
20
|
+
* "多试几次总会有一次过",那是退让而不是治理。上限的作用是让抖动
|
|
21
|
+
* 暴露出来,而不是让它消失。
|
|
22
|
+
*
|
|
23
|
+
* 3. **隔离台账必须有到期。** 没有到期的隔离就是永久删除(只是看起来还在)。
|
|
24
|
+
* 这条和 #608 的 escape valve 同一条教训:没有失效机制的安全阀,
|
|
25
|
+
* 一年后就成了常态。
|
|
26
|
+
* @covers AC-603-01, AC-603-02, AC-603-03, AC-603-04
|
|
27
|
+
*/
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const os = require('node:os');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
const { computeFlakyStats, parseQuarantine, findExpiredQuarantine, MAX_RETRIES } = require('../flaky');
|
|
34
|
+
|
|
35
|
+
function rec(overrides) {
|
|
36
|
+
return {
|
|
37
|
+
commit_hash: 'deadbeef',
|
|
38
|
+
duration_ms: 1000,
|
|
39
|
+
gate_id: '5',
|
|
40
|
+
gate_name: 'Tests + Coverage',
|
|
41
|
+
issues_found: 0,
|
|
42
|
+
passed: true,
|
|
43
|
+
repo_path: '/repo',
|
|
44
|
+
timestamp: '2026-10-30T00:00:00Z',
|
|
45
|
+
trigger: 'pre-commit',
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('flaky 测试治理 (#603)', () => {
|
|
51
|
+
it('AC-603-01: 重跑后通过不计入首过率', () => {
|
|
52
|
+
const stats = computeFlakyStats([
|
|
53
|
+
rec({ passed: true, attempt: 1 }),
|
|
54
|
+
rec({ passed: false, attempt: 1 }),
|
|
55
|
+
rec({ passed: true, attempt: 2, previously_failed: true }),
|
|
56
|
+
rec({ passed: true, attempt: 1 }),
|
|
57
|
+
]);
|
|
58
|
+
// 4 个 gate 运行,其中 3 个是首次尝试(1 过 1 失 1 过 —— 第 4 条也是首次)。
|
|
59
|
+
// 首过率 = 首过数 / 首次尝试数。重跑成功那一条既不算首过,也不算首次尝试。
|
|
60
|
+
expect(stats.firstAttempts).toBe(3);
|
|
61
|
+
expect(stats.firstPasses).toBe(2);
|
|
62
|
+
expect(stats.firstPassRate).toBeCloseTo(66.7, 1);
|
|
63
|
+
// 重跑成功被单独计量——这正是需要被看见的那一类。
|
|
64
|
+
// 分母是全部运行(3 次首次 + 1 次重跑 = 4),所以 1/4 = 25%。
|
|
65
|
+
expect(stats.flakyRecoveries).toBe(1);
|
|
66
|
+
expect(stats.flakyRate).toBeCloseTo(25, 1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('AC-603-02: 没有重跑记录时 flakyRate 为 null 而非 0(不可测量≠零抖动)', () => {
|
|
70
|
+
// 一个从没记录过重跑的 journal 说明不了"测试很稳",只能说明"没度量"。
|
|
71
|
+
// 报 0 会让读者以为已经量过而且是好的——和 #651 的 NOT MEASURABLE 同一条教训。
|
|
72
|
+
const stats = computeFlakyStats([rec({ passed: true, attempt: 1 })]);
|
|
73
|
+
expect(stats.flakyRate).toBeNull();
|
|
74
|
+
expect(stats.measurable).toBe(false);
|
|
75
|
+
expect(stats.reason).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('AC-603-03: 重试上限有硬上限,配置不能无限放大', () => {
|
|
79
|
+
// 无上限重试等于把门禁变成"多试几次总会有一次过"。
|
|
80
|
+
expect(MAX_RETRIES).toBeGreaterThan(0);
|
|
81
|
+
expect(MAX_RETRIES).toBeLessThanOrEqual(5);
|
|
82
|
+
const { resolveMaxRetries } = require('../flaky');
|
|
83
|
+
expect(resolveMaxRetries(undefined)).toBe(1);
|
|
84
|
+
expect(resolveMaxRetries('0')).toBe(0);
|
|
85
|
+
expect(resolveMaxRetries('99')).toBe(MAX_RETRIES);
|
|
86
|
+
expect(resolveMaxRetries('-3')).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('AC-603-04: 隔离台账解析出到期项,逾期必须被查出且核心链路不得长期隔离', () => {
|
|
90
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flaky-'));
|
|
91
|
+
const file = path.join(dir, '.flaky-quarantine.yaml');
|
|
92
|
+
fs.writeFileSync(file, [
|
|
93
|
+
'quarantined:',
|
|
94
|
+
' - test: "src/a.test.ts > flaky one"',
|
|
95
|
+
' owner: alice',
|
|
96
|
+
' issue: 601',
|
|
97
|
+
' expiry: 2026-01-01',
|
|
98
|
+
' reason: "timing-dependent assertion"',
|
|
99
|
+
' - test: "src/b.test.ts > ok one"',
|
|
100
|
+
' owner: bob',
|
|
101
|
+
' issue: 602',
|
|
102
|
+
' expiry: 2099-01-01',
|
|
103
|
+
' reason: "upstream bug"',
|
|
104
|
+
' - test: "src/c.test.ts > critical"',
|
|
105
|
+
' owner: carol',
|
|
106
|
+
' issue: 603',
|
|
107
|
+
' expiry: 2099-01-01',
|
|
108
|
+
' reason: "known"',
|
|
109
|
+
' critical_path: true',
|
|
110
|
+
'',
|
|
111
|
+
].join('\n'));
|
|
112
|
+
const entries = parseQuarantine(file);
|
|
113
|
+
expect(entries).toHaveLength(3);
|
|
114
|
+
expect(entries[0].owner).toBe('alice');
|
|
115
|
+
|
|
116
|
+
const expired = findExpiredQuarantine(entries, new Date('2026-06-01T00:00:00Z'));
|
|
117
|
+
expect(expired.map((e) => e.owner)).toEqual(['alice']);
|
|
118
|
+
|
|
119
|
+
const { findPolicyViolations } = require('../flaky');
|
|
120
|
+
// A critical-path test may not sit in quarantine past its expiry either --
|
|
121
|
+
// that is the whole point of marking it critical.
|
|
122
|
+
const violations = findPolicyViolations(entries, new Date('2026-06-01T00:00:00Z'));
|
|
123
|
+
const owners = violations.map((v) => v.entry.owner);
|
|
124
|
+
expect(owners).toContain('alice');
|
|
125
|
+
expect(owners).toContain('carol');
|
|
126
|
+
expect(owners).not.toContain('bob');
|
|
127
|
+
});
|
|
128
|
+
});
|
package/lib/doctor.js
CHANGED
|
@@ -26,6 +26,7 @@ const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash } = require('
|
|
|
26
26
|
// generated shell projection of this same module.
|
|
27
27
|
const { RESOLUTION_ORDER } = require('./hook-module-resolution.js');
|
|
28
28
|
const { inspectHookFile } = require('./hook-health.js');
|
|
29
|
+
const { parseQuarantine, findPolicyViolations } = require('./flaky.js');
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Files above this size get a `bash -n` probe in addition to the CRLF scan.
|
|
@@ -830,6 +831,48 @@ function diagnoseAuditLedger(ctx = {}) {
|
|
|
830
831
|
return { checks, issues };
|
|
831
832
|
}
|
|
832
833
|
|
|
834
|
+
/**
|
|
835
|
+
* Check the flaky-test quarantine ledger for expired entries (#603).
|
|
836
|
+
*
|
|
837
|
+
* This lives in doctor rather than in the gate path on purpose: an expired
|
|
838
|
+
* quarantine is not a code defect, it is a decision that was never revisited.
|
|
839
|
+
* Nothing fails, everything keeps running, and a test meant to be fixed within
|
|
840
|
+
* two weeks quietly stays disabled for a year. That is exactly the class of drift
|
|
841
|
+
* doctor exists to surface, and exactly the class no gate can block.
|
|
842
|
+
*/
|
|
843
|
+
function diagnoseFlakyQuarantine(ctx = {}) {
|
|
844
|
+
const checks = [];
|
|
845
|
+
let issues = 0;
|
|
846
|
+
const root = ctx.repoRoot || process.cwd();
|
|
847
|
+
let entries = [];
|
|
848
|
+
try {
|
|
849
|
+
entries = parseQuarantine(path.join(root, '.flaky-quarantine.yaml'));
|
|
850
|
+
} catch {
|
|
851
|
+
return { checks, issues };
|
|
852
|
+
}
|
|
853
|
+
// No ledger is PASS, not SKIP: a project that quarantines nothing is genuinely
|
|
854
|
+
// in good shape, and SKIP would imply the check had not been performed.
|
|
855
|
+
if (entries.length === 0) {
|
|
856
|
+
checks.push({ name: 'Flaky quarantine', status: 'PASS', detail: 'no quarantined tests' });
|
|
857
|
+
return { checks, issues };
|
|
858
|
+
}
|
|
859
|
+
const violations = findPolicyViolations(entries, new Date());
|
|
860
|
+
if (violations.length === 0) {
|
|
861
|
+
checks.push({
|
|
862
|
+
name: 'Flaky quarantine',
|
|
863
|
+
status: 'PASS',
|
|
864
|
+
detail: `${entries.length} quarantined test(s), all within expiry`,
|
|
865
|
+
});
|
|
866
|
+
return { checks, issues };
|
|
867
|
+
}
|
|
868
|
+
issues += violations.length;
|
|
869
|
+
const detail = violations
|
|
870
|
+
.map((v) => `${v.rule}: ${v.entry.test} (owner ${v.entry.owner || 'unassigned'}, expiry ${v.entry.expiry || 'none'})`)
|
|
871
|
+
.join('; ');
|
|
872
|
+
checks.push({ name: 'Flaky quarantine', status: 'WARN', detail });
|
|
873
|
+
return { checks, issues };
|
|
874
|
+
}
|
|
875
|
+
|
|
833
876
|
function diagnoseModuleDrift(ctx = {}) {
|
|
834
877
|
const checks = [];
|
|
835
878
|
let issues = 0;
|
|
@@ -3020,6 +3063,19 @@ async function doctor(args) {
|
|
|
3020
3063
|
issues += hookDrift.issues + moduleDrift.issues;
|
|
3021
3064
|
}
|
|
3022
3065
|
|
|
3066
|
+
// Flaky-test quarantine expiry (#603). A WARN, not a FAIL: an overdue ledger
|
|
3067
|
+
// breaks nothing at runtime, it only means an intentional gap outlived its
|
|
3068
|
+
// intent -- doctor's business, not a gate's.
|
|
3069
|
+
try {
|
|
3070
|
+
const flaky = diagnoseFlakyQuarantine({ repoRoot: process.cwd() });
|
|
3071
|
+
checks.push(...flaky.checks);
|
|
3072
|
+
issues += flaky.issues;
|
|
3073
|
+
} catch (err) {
|
|
3074
|
+
// Surface the cause: a bare "could not be read" hid the real error and made
|
|
3075
|
+
// this look like a missing file rather than a bug in the check itself.
|
|
3076
|
+
checks.push({ name: 'Flaky quarantine', status: 'SKIP', detail: `ledger check failed: ${err && err.message ? err.message : String(err)}` });
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3023
3079
|
let langIssues = 0;
|
|
3024
3080
|
// Upgrade + plugin + language-tool status, kept as one callable because a
|
|
3025
3081
|
// --format json run still has to execute all of it (#488).
|
package/lib/flaky.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flaky-test governance (#603).
|
|
3
|
+
*
|
|
4
|
+
* The gap this closes: a flaky test and a genuine failure look identical in the
|
|
5
|
+
* gate journal, so one of two wrong things happens — the flake gets treated as a
|
|
6
|
+
* product bug and someone spends a day "fixing" nothing, or the real failure gets
|
|
7
|
+
* re-run until it passes and slips through. Both are consequences of having no
|
|
8
|
+
* data that separates the two.
|
|
9
|
+
*
|
|
10
|
+
* Three rules, each earned from a specific failure mode:
|
|
11
|
+
*
|
|
12
|
+
* 1. A re-run that passes does NOT count as a first pass. If `passed: true` counted
|
|
13
|
+
* the same regardless of how it was obtained, first-pass rate would be
|
|
14
|
+
* permanently 100% and the metric would measure nothing.
|
|
15
|
+
* 2. Retries are capped, and the default is tiny. Unlimited retries turn a gate
|
|
16
|
+
* into "eventually something passes", which is a concession rather than
|
|
17
|
+
* governance.
|
|
18
|
+
* 3. Quarantine entries expire. An entry with no expiry is a permanent deletion
|
|
19
|
+
* that merely still looks present.
|
|
20
|
+
*
|
|
21
|
+
* No runtime dependencies: this ships inside the zero-install package.
|
|
22
|
+
*/
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hard ceiling on retries. Config may lower this but never raise it: the cap
|
|
29
|
+
* exists so flakiness surfaces rather than being retried away.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_RETRIES = 3;
|
|
32
|
+
|
|
33
|
+
/** Only schedule YAML is needed, so a tiny parser beats a dependency here. */
|
|
34
|
+
const FIELD_RE = /^\s*-\s+test:\s*(.+?)\s*$/
|
|
35
|
+
const KV_RE = /^\s+([A-Za-z_][\w]*):\s*(.*?)\s*$/
|
|
36
|
+
|
|
37
|
+
function stripQuotes(value) {
|
|
38
|
+
if (value.length >= 2) {
|
|
39
|
+
const first = value[0]
|
|
40
|
+
const last = value[value.length - 1]
|
|
41
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
42
|
+
return value.slice(1, -1)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse `.flaky-quarantine.yaml`.
|
|
50
|
+
*
|
|
51
|
+
* Returns `[]` for a missing file: no ledger is not an error, it just means
|
|
52
|
+
* nothing is quarantined. A malformed entry is skipped rather than throwing —
|
|
53
|
+
* a broken ledger must not take down `doctor`.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} filePath
|
|
56
|
+
* @returns {Array<object>}
|
|
57
|
+
*/
|
|
58
|
+
function parseQuarantine(filePath) {
|
|
59
|
+
let text
|
|
60
|
+
try {
|
|
61
|
+
text = fs.readFileSync(filePath, 'utf8')
|
|
62
|
+
} catch {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
const entries = []
|
|
66
|
+
let current = null
|
|
67
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
68
|
+
const line = rawLine.replace(/\s+$/, '')
|
|
69
|
+
if (line === '' || line.startsWith('#')) continue
|
|
70
|
+
const start = FIELD_RE.exec(line)
|
|
71
|
+
if (start) {
|
|
72
|
+
if (current) entries.push(current)
|
|
73
|
+
current = { test: stripQuotes(start[1]) }
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (!current) continue
|
|
77
|
+
const kv = KV_RE.exec(line)
|
|
78
|
+
if (!kv) continue
|
|
79
|
+
const key = kv[1]
|
|
80
|
+
const value = stripQuotes(kv[2])
|
|
81
|
+
if (key === 'critical_path') {
|
|
82
|
+
current.critical_path = value === 'true'
|
|
83
|
+
} else {
|
|
84
|
+
current[key] = value
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (current) entries.push(current)
|
|
88
|
+
return entries
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parse an `YYYY-MM-DD` expiry into a UTC midnight Date, or null. */
|
|
92
|
+
function parseExpiry(value) {
|
|
93
|
+
if (typeof value !== 'string') return null
|
|
94
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim())
|
|
95
|
+
if (!m) return null
|
|
96
|
+
const date = new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00Z`)
|
|
97
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Entries whose expiry has passed. An unparseable expiry counts as expired. */
|
|
101
|
+
function findExpiredQuarantine(entries, now) {
|
|
102
|
+
const at = now || new Date()
|
|
103
|
+
return entries.filter((entry) => {
|
|
104
|
+
const expiry = parseExpiry(entry.expiry)
|
|
105
|
+
// No expiry at all is treated as expired: rule 3 says quarantine without an
|
|
106
|
+
// end date is a permanent deletion, so it must be reported, not tolerated.
|
|
107
|
+
if (expiry === null) return true
|
|
108
|
+
return expiry.getTime() < at.getTime()
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Policy violations across the ledger.
|
|
114
|
+
*
|
|
115
|
+
* Two distinct rules are reported separately because they need different
|
|
116
|
+
* responses: an expired entry is routine bookkeeping, whereas a critical-path
|
|
117
|
+
* entry is a decision someone has to make — a critical path that has been flaky
|
|
118
|
+
* for months means the team has stopped trusting its own safety net.
|
|
119
|
+
*/
|
|
120
|
+
function findPolicyViolations(entries, now) {
|
|
121
|
+
const at = now || new Date()
|
|
122
|
+
const violations = []
|
|
123
|
+
for (const entry of findExpiredQuarantine(entries, at)) {
|
|
124
|
+
violations.push({ rule: 'expired', entry })
|
|
125
|
+
}
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.critical_path && !violations.some((v) => v.entry === entry)) {
|
|
128
|
+
violations.push({ rule: 'critical-path-quarantined', entry })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return violations
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve the retry budget from configuration.
|
|
136
|
+
*
|
|
137
|
+
* Clamped, not validated-with-an-error: a bad value must not make the gate
|
|
138
|
+
* unusable, and the direction of clamping always favours surfacing flakiness.
|
|
139
|
+
*/
|
|
140
|
+
function resolveMaxRetries(value) {
|
|
141
|
+
if (value === undefined || value === null || value === '') return 1
|
|
142
|
+
const parsed = Number.parseInt(String(value), 10)
|
|
143
|
+
if (Number.isNaN(parsed)) return 1
|
|
144
|
+
if (parsed < 0) return 0
|
|
145
|
+
return Math.min(parsed, MAX_RETRIES)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Round to one decimal place without trailing `.0`. */
|
|
149
|
+
function pct(part, whole) {
|
|
150
|
+
if (whole === 0) return null
|
|
151
|
+
return Math.round((part / whole) * 1000) / 10
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compute first-pass and flaky statistics from gate-journal entries.
|
|
156
|
+
*
|
|
157
|
+
* Records carry `attempt` (1-based). A record with `attempt > 1` is a re-run and
|
|
158
|
+
* is excluded from the first-attempt denominator — that exclusion IS the metric.
|
|
159
|
+
*/
|
|
160
|
+
function computeFlakyStats(entries) {
|
|
161
|
+
const rows = Array.isArray(entries) ? entries : []
|
|
162
|
+
let firstAttempts = 0
|
|
163
|
+
let firstPasses = 0
|
|
164
|
+
let flakyRecoveries = 0
|
|
165
|
+
let reruns = 0
|
|
166
|
+
let quarantined = 0
|
|
167
|
+
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
if (!row || typeof row !== 'object') continue
|
|
170
|
+
if (row.quarantined === true) quarantined += 1
|
|
171
|
+
const attempt = Number.isFinite(row.attempt) ? row.attempt : 1
|
|
172
|
+
if (attempt > 1) {
|
|
173
|
+
reruns += 1
|
|
174
|
+
if (row.passed === true) flakyRecoveries += 1
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
firstAttempts += 1
|
|
178
|
+
if (row.passed === true) firstPasses += 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const measurable = reruns > 0
|
|
182
|
+
return {
|
|
183
|
+
totalRuns: rows.length,
|
|
184
|
+
firstAttempts,
|
|
185
|
+
firstPasses,
|
|
186
|
+
firstPassRate: pct(firstPasses, firstAttempts),
|
|
187
|
+
reruns,
|
|
188
|
+
flakyRecoveries,
|
|
189
|
+
quarantined,
|
|
190
|
+
// Without any re-run there is no evidence either way. Reporting 0 here would
|
|
191
|
+
// claim "measured, and stable" from data that only says "not measured".
|
|
192
|
+
flakyRate: measurable ? pct(flakyRecoveries, firstAttempts + reruns) : null,
|
|
193
|
+
measurable,
|
|
194
|
+
reason: measurable
|
|
195
|
+
? null
|
|
196
|
+
: 'no re-run records in the journal — flakiness is unmeasured, not zero',
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
MAX_RETRIES,
|
|
202
|
+
parseQuarantine,
|
|
203
|
+
parseExpiry,
|
|
204
|
+
findExpiredQuarantine,
|
|
205
|
+
findPolicyViolations,
|
|
206
|
+
resolveMaxRetries,
|
|
207
|
+
computeFlakyStats,
|
|
208
|
+
}
|
package/lib/gate-audit.ts
CHANGED
|
@@ -47,6 +47,17 @@ export interface GateAuditEntry {
|
|
|
47
47
|
* one that drives fixes. Only meaningful when passed=false.
|
|
48
48
|
*/
|
|
49
49
|
failure_reason?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Which attempt this record represents, 1-based (#603).
|
|
52
|
+
*
|
|
53
|
+
* Absent means 1 — older records predate re-run tracking. This field is what
|
|
54
|
+
* makes first-pass rate computable: without it, "passed" cannot distinguish a
|
|
55
|
+
* clean run from one that only passed after a re-run, and a metric that cannot
|
|
56
|
+
* tell those apart is always 100%.
|
|
57
|
+
*/
|
|
58
|
+
attempt?: number;
|
|
59
|
+
/** True when this row describes a test that is quarantined as flaky (#603). */
|
|
60
|
+
quarantined?: boolean;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
@@ -423,6 +434,10 @@ function runCli(): void {
|
|
|
423
434
|
...(opts['detail'] ? { detail: opts['detail'] } : {}),
|
|
424
435
|
// #651: only set when provided, so successful records stay byte-identical.
|
|
425
436
|
...(opts['failure-reason'] ? { failure_reason: String(opts['failure-reason']).slice(0, 300) } : {}),
|
|
437
|
+
// #603: only set when provided, so single-attempt records stay byte-identical
|
|
438
|
+
// to what earlier versions wrote.
|
|
439
|
+
...(opts['attempt'] ? { attempt: Number.parseInt(String(opts['attempt']), 10) || 1 } : {}),
|
|
440
|
+
...(opts['quarantined'] === 'true' ? { quarantined: true } : {}),
|
|
426
441
|
};
|
|
427
442
|
// Pre-commit (trigger=commit) is staging for a commit that does not exist
|
|
428
443
|
// yet: park it in the pending sidecar so post-commit can stamp the real
|