@boyingliu01/xp-gate 0.24.0 → 0.25.0

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.
Files changed (44) hide show
  1. package/adapter-common.sh +51 -2
  2. package/adapters/python.sh +10 -2
  3. package/hooks/adapter-common.sh +51 -2
  4. package/hooks/lib/coverage-total.sh +20 -0
  5. package/hooks/lib/python-test-selection.sh +132 -0
  6. package/hooks/pre-commit +100 -11
  7. package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
  8. package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
  9. package/lib/init.js +46 -1
  10. package/lib/mattpocock-license-verdict.json +1 -1
  11. package/lib/phase-transition.js +61 -0
  12. package/lib/sprint-state-manager.js +86 -39
  13. package/lib/sprint-state-migrator.js +60 -22
  14. package/mock-policy/AGENTS.md +2 -2
  15. package/mutation/AGENTS.md +2 -2
  16. package/mutation/__tests__/gremlins-runner.test.ts +167 -0
  17. package/mutation/__tests__/mutmut-runner.test.ts +20 -22
  18. package/mutation/__tests__/runners-index.test.ts +31 -3
  19. package/mutation/gate-m.ts +22 -4
  20. package/mutation/runners/gremlins-runner.ts +238 -0
  21. package/mutation/runners/index.ts +9 -1
  22. package/mutation/runners/mutmut-runner.ts +18 -12
  23. package/mutation/runners/types.ts +20 -0
  24. package/package.json +1 -1
  25. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  26. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  27. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  28. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/plugins/dsh/package.json +1 -1
  30. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  31. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  32. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/plugins/opencode/package.json +1 -1
  34. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  35. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  36. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  37. package/plugins/qoder/plugin.json +1 -1
  38. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  39. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  40. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  41. package/principles/AGENTS.md +2 -2
  42. package/skills/delphi-review/AGENTS.md +2 -2
  43. package/skills/sprint-flow/AGENTS.md +2 -2
  44. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-14
4
- **Commit:** e466ce16
4
+ **Commit:** a757d625
5
5
  **Branch:** main
6
- **Version:** 0.24.0.0
6
+ **Version:** 0.25.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @intent #410: mutmut 3.x depends on fork() and cannot run natively on
3
+ * Windows — Gate M (Python) formed a structural deadlock (installed =>
4
+ * always blocked, uninstalled => always skipped). pytest-gremlins is
5
+ * architecturally cross-platform (mutation switching, no fork), so it
6
+ * becomes the second engine. This runner drives
7
+ * `pytest --gremlins` and parses the console report.
8
+ * @covers GremlinsRunner availability probe + console report parsing
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
+ import { spawn, execSync, type ChildProcess } from 'child_process';
13
+
14
+ vi.mock('child_process', () => ({
15
+ spawn: vi.fn(),
16
+ execSync: vi.fn(),
17
+ }));
18
+
19
+ /** Capture the stdout/close callbacks so the test can drive them by hand. */
20
+ function mockChild(capture: {
21
+ stdout?: (cb: (d: Buffer) => void) => void;
22
+ stderr?: (cb: (d: Buffer) => void) => void;
23
+ close?: (cb: (code: number | null) => void) => void;
24
+ error?: (cb: (e: Error) => void) => void;
25
+ }): ChildProcess {
26
+ return {
27
+ stdout: { on: (_e: string, cb: (d: Buffer) => void) => capture.stdout?.(cb) },
28
+ stderr: { on: (_e: string, cb: (d: Buffer) => void) => capture.stderr?.(cb) },
29
+ on: (event: string, cb: (arg: never) => void) => {
30
+ if (event === 'close') capture.close?.(cb as (code: number | null) => void);
31
+ if (event === 'error') capture.error?.(cb as unknown as (e: Error) => void);
32
+ },
33
+ kill: vi.fn(),
34
+ } as unknown as ChildProcess;
35
+ }
36
+
37
+ describe('GremlinsRunner', () => {
38
+ let GremlinsRunner: typeof import('../runners/gremlins-runner').GremlinsRunner;
39
+
40
+ beforeEach(async () => {
41
+ vi.clearAllMocks();
42
+ vi.mocked(execSync).mockReset();
43
+ vi.mocked(spawn).mockReset();
44
+ vi.mocked(execSync).mockReturnValue('' as never);
45
+ GremlinsRunner = (await import('../runners/gremlins-runner')).GremlinsRunner;
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.useRealTimers();
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ it('declares the pytest-gremlins engine name and the py extension', () => {
54
+ const runner = new GremlinsRunner();
55
+ expect(runner.name).toBe('pytest-gremlins');
56
+ expect(runner.extensions).toEqual(['py']);
57
+ });
58
+
59
+ it('isAvailable probes the pytest_gremlins import', async () => {
60
+ const runner = new GremlinsRunner();
61
+ await expect(runner.isAvailable()).resolves.toBe(true);
62
+ const probed = vi.mocked(execSync).mock.calls[0]?.[0] as string;
63
+ expect(probed).toContain('import pytest_gremlins');
64
+ });
65
+
66
+ it('isAvailable returns false when the import probe fails', async () => {
67
+ vi.mocked(execSync).mockImplementation(() => {
68
+ throw new Error('ModuleNotFoundError');
69
+ });
70
+ const runner = new GremlinsRunner();
71
+ await expect(runner.isAvailable()).resolves.toBe(false);
72
+ });
73
+
74
+ it('parses the console report (Zapped/Survived) into a normalized result', async () => {
75
+ let closeCb: ((code: number | null) => void) | undefined;
76
+ let stdoutCb: ((d: Buffer) => void) | undefined;
77
+ vi.mocked(spawn).mockReturnValue(
78
+ mockChild({
79
+ stdout: cb => { stdoutCb = cb; },
80
+ close: cb => { closeCb = cb; },
81
+ }),
82
+ );
83
+
84
+ const runner = new GremlinsRunner();
85
+ const promise = runner.run({ files: ['src/foo.py'], timeoutMs: 5000, cwd: '/tmp' });
86
+ await new Promise(r => setTimeout(r, 0));
87
+
88
+ stdoutCb?.(
89
+ Buffer.from(
90
+ '================== pytest-gremlins mutation report ==================\n' +
91
+ 'Zapped: 142 gremlins (89%)\n' +
92
+ 'Survived: 18 gremlins (11%)\n' +
93
+ '=====================================================================\n',
94
+ ),
95
+ );
96
+ closeCb?.(0);
97
+
98
+ const outcome = await promise;
99
+ expect(outcome.report).not.toBeNull();
100
+ expect(outcome.report?.nrOfKilledMutants).toBe(142);
101
+ expect(outcome.report?.nrOfSurvivedMutants).toBe(18);
102
+ expect(outcome.report?.nrOfMutants).toBe(160);
103
+ expect(Math.round(outcome.report!.mutationScore)).toBe(89);
104
+ expect(outcome.timedOut).toBe(false);
105
+ });
106
+
107
+ it('returns report=null (never throws) when the report lines are absent', async () => {
108
+ let closeCb: ((code: number | null) => void) | undefined;
109
+ vi.mocked(spawn).mockReturnValue(mockChild({ stdout: () => {}, close: cb => { closeCb = cb; } }));
110
+
111
+ const runner = new GremlinsRunner();
112
+ const promise = runner.run({ files: ['src/foo.py'], timeoutMs: 5000, cwd: '/tmp' });
113
+ await new Promise(r => setTimeout(r, 0));
114
+ closeCb?.(1);
115
+
116
+ const outcome = await promise;
117
+ expect(outcome.report).toBeNull();
118
+ });
119
+
120
+ it('returns report=null when the report has zero gremlins', async () => {
121
+ let closeCb: ((code: number | null) => void) | undefined;
122
+ let stdoutCb: ((d: Buffer) => void) | undefined;
123
+ vi.mocked(spawn).mockReturnValue(
124
+ mockChild({ stdout: cb => { stdoutCb = cb; }, close: cb => { closeCb = cb; } }),
125
+ );
126
+
127
+ const runner = new GremlinsRunner();
128
+ const promise = runner.run({ files: ['src/foo.py'], timeoutMs: 5000, cwd: '/tmp' });
129
+ await new Promise(r => setTimeout(r, 0));
130
+ stdoutCb?.(Buffer.from('Zapped: 0 gremlins (0%)\nSurvived: 0 gremlins (0%)\n'));
131
+ closeCb?.(0);
132
+
133
+ const outcome = await promise;
134
+ expect(outcome.report).toBeNull();
135
+ });
136
+
137
+ it('passes --gremlin-targets derived from the changed file directories', async () => {
138
+ let closeCb: ((code: number | null) => void) | undefined;
139
+ vi.mocked(spawn).mockReturnValue(mockChild({ stdout: () => {}, close: cb => { closeCb = cb; } }));
140
+
141
+ const runner = new GremlinsRunner();
142
+ const promise = runner.run({ files: ['src/foo/bar.py', 'src/baz.py'], timeoutMs: 5000, cwd: '/tmp' });
143
+ await new Promise(r => setTimeout(r, 0));
144
+ closeCb?.(0);
145
+ await promise;
146
+
147
+ const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1] as string[];
148
+ const joined = spawnArgs.join(' ');
149
+ expect(joined).toContain('--gremlins');
150
+ expect(joined).toContain('--gremlin-targets=src/foo,src');
151
+ });
152
+
153
+ it('reports timedOut when the child exceeds the deadline', async () => {
154
+ let closeCb: ((code: number | null) => void) | undefined;
155
+ const child = mockChild({ stdout: () => {}, close: cb => { closeCb = cb; } });
156
+ vi.mocked(spawn).mockReturnValue(child);
157
+
158
+ const runner = new GremlinsRunner();
159
+ const promise = runner.run({ files: ['src/foo.py'], timeoutMs: 1, cwd: '/tmp' });
160
+ await new Promise(r => setTimeout(r, 20));
161
+ closeCb?.(null);
162
+
163
+ const outcome = await promise;
164
+ expect(outcome.timedOut).toBe(true);
165
+ expect(child.kill).toHaveBeenCalled();
166
+ });
167
+ });
@@ -119,7 +119,7 @@ describe('MutmutRunner', () => {
119
119
  it('should return true when mutmut is installed', async () => {
120
120
  vi.mocked(execSync).mockReturnValue('mutmut, version 3.0.0\n');
121
121
 
122
- const runner = new MutmutRunner();
122
+ const runner = new MutmutRunner('linux');
123
123
  const result = await runner.isAvailable();
124
124
  expect(result).toBe(true);
125
125
  expect(execSync).toHaveBeenCalledWith('mutmut --version', {
@@ -150,9 +150,11 @@ describe('MutmutRunner', () => {
150
150
  const result = await runner.isAvailable();
151
151
 
152
152
  expect(result).toBe(true);
153
- expect(execSync).toHaveBeenNthCalledWith(2, 'wsl mutmut --version', {
153
+ // #410: win32 route is decided by the WSL probe alone (no native probe).
154
+ expect(execSync).toHaveBeenCalledTimes(1);
155
+ expect(execSync).toHaveBeenCalledWith('wsl mutmut --version', {
154
156
  stdio: 'pipe',
155
- timeout: 5000,
157
+ timeout: 10000,
156
158
  });
157
159
  });
158
160
 
@@ -205,7 +207,7 @@ describe('MutmutRunner', () => {
205
207
  // No existing pyproject.toml
206
208
  vi.mocked(existsSync).mockReturnValue(false);
207
209
 
208
- const runner = new MutmutRunner();
210
+ const runner = new MutmutRunner('linux');
209
211
  const promise = runner.run({
210
212
  files: ['src/main.py'],
211
213
  timeoutMs: 60000,
@@ -235,14 +237,12 @@ describe('MutmutRunner', () => {
235
237
  expect(result.report!.nrOfSurvivedMutants).toBe(29);
236
238
  });
237
239
 
238
- it('should execute native mutmut when native detection succeeds on Windows', async () => {
239
- let closeCb: ((code: number | null) => void) | null = null;
240
+ it('should be unavailable on Windows when WSL is absent even if native mutmut is installed', async () => {
240
241
  const mockChild = {
241
242
  stdout: { on: vi.fn() },
242
243
  stderr: { on: vi.fn() },
243
- on: vi.fn((event: string, cb: (code: number | null) => void) => {
244
- if (event === 'close') closeCb = cb;
245
- }),
244
+ // Unavailable route: `run()` never spawns, so `close` is never subscribed.
245
+ on: vi.fn(),
246
246
  kill: vi.fn(),
247
247
  killed: false,
248
248
  };
@@ -254,22 +254,17 @@ describe('MutmutRunner', () => {
254
254
  vi.mocked(existsSync).mockReturnValue(false);
255
255
 
256
256
  const runner = new MutmutRunner('win32');
257
- expect(await runner.isAvailable()).toBe(true);
258
- const promise = runner.run({
257
+ // #410: native mutmut on Windows proves nothing about `mutmut run`
258
+ // (fork()-based). The WSL route decides availability; no WSL → SKIP.
259
+ expect(await runner.isAvailable()).toBe(false);
260
+ const outcome = await runner.run({
259
261
  files: ['src/main.py'],
260
262
  timeoutMs: 60000,
261
263
  cwd: 'C:\\work\\project',
262
264
  });
263
- await waitForSpawn();
264
- closeCb!(0);
265
- await promise;
266
265
 
267
- expect(execSync).toHaveBeenCalledTimes(1);
268
- expect(spawn).toHaveBeenCalledWith(
269
- 'mutmut',
270
- ['run'],
271
- expect.objectContaining({ cwd: 'C:\\work\\project' }),
272
- );
266
+ expect(outcome).toEqual({ report: null, timedOut: false });
267
+ expect(spawn).not.toHaveBeenCalled();
273
268
  });
274
269
 
275
270
  it('should execute selected WSL route with Linux --cd and Windows host cwd', async () => {
@@ -428,7 +423,7 @@ describe('MutmutRunner', () => {
428
423
  expect(vi.mocked(spawn).mock.calls[0]?.[0]).toBe('wsl');
429
424
  });
430
425
 
431
- it('should follow repeated WSL to native availability transitions', async () => {
426
+ it('should keep the WSL route on Windows even when native mutmut becomes available', async () => {
432
427
  const mockChild = {
433
428
  stdout: { on: vi.fn() },
434
429
  stderr: { on: vi.fn() },
@@ -450,10 +445,13 @@ describe('MutmutRunner', () => {
450
445
  const runner = new MutmutRunner('win32');
451
446
  expect(await runner.isAvailable()).toBe(true);
452
447
  nativeAvailable = true;
448
+ // #410: even when native `mutmut --version` starts succeeding, the win32
449
+ // route is decided by WSL alone — native success proves nothing about
450
+ // `mutmut run` (fork()). The route must NOT flip to native.
453
451
  expect(await runner.isAvailable()).toBe(true);
454
452
  await runner.run({ files: ['src/main.py'], timeoutMs: 60000, cwd: 'C:\\repo' });
455
453
 
456
- expect(vi.mocked(spawn).mock.calls[0]?.[0]).toBe('mutmut');
454
+ expect(vi.mocked(spawn).mock.calls[0]?.[0]).toBe('wsl');
457
455
  });
458
456
 
459
457
  it('should stop spawning after availability transitions to unavailable', async () => {
@@ -10,6 +10,7 @@ import {
10
10
  runnerRegistry,
11
11
  registerAllRunners,
12
12
  resolveRunner,
13
+ resolveRunners,
13
14
  StrykerRunner,
14
15
  MutmutRunner,
15
16
  GoMutantRunner,
@@ -32,11 +33,12 @@ describe('runners/index', () => {
32
33
  expect(runnerRegistry).toBeInstanceOf(Map);
33
34
  });
34
35
 
35
- it('registerAllRunners() registers all 4 runners', () => {
36
+ it('registerAllRunners() registers all 5 runners', () => {
36
37
  registerAllRunners();
37
38
 
38
- expect(runnerRegistry.size).toBe(4);
39
+ expect(runnerRegistry.size).toBe(5);
39
40
  expect(runnerRegistry.has('Stryker')).toBe(true);
41
+ expect(runnerRegistry.has('pytest-gremlins')).toBe(true);
40
42
  expect(runnerRegistry.has('mutmut')).toBe(true);
41
43
  expect(runnerRegistry.has('gomutants')).toBe(true);
42
44
  expect(runnerRegistry.has('pitest')).toBe(true);
@@ -46,6 +48,7 @@ describe('runners/index', () => {
46
48
  registerAllRunners();
47
49
 
48
50
  expect(runnerRegistry.get('Stryker')).toBeInstanceOf(StrykerRunner);
51
+ expect(runnerRegistry.get('pytest-gremlins')).toBeDefined();
49
52
  expect(runnerRegistry.get('mutmut')).toBeInstanceOf(MutmutRunner);
50
53
  expect(runnerRegistry.get('gomutants')).toBeInstanceOf(GoMutantRunner);
51
54
  expect(runnerRegistry.get('pitest')).toBeInstanceOf(PitestRunner);
@@ -59,7 +62,7 @@ describe('runners/index', () => {
59
62
  it.each([
60
63
  ['ts', 'Stryker'],
61
64
  ['tsx', 'Stryker'],
62
- ['py', 'mutmut'],
65
+ ['py', 'pytest-gremlins'],
63
66
  ['go', 'gomutants'],
64
67
  ['java', 'pitest'],
65
68
  ['kt', 'pitest'],
@@ -83,6 +86,31 @@ describe('runners/index', () => {
83
86
  });
84
87
  });
85
88
 
89
+ describe('resolveRunners()', () => {
90
+ beforeEach(() => {
91
+ registerAllRunners();
92
+ });
93
+
94
+ it('returns the py runners in registration (priority) order for ".py"', () => {
95
+ const runners = resolveRunners('.py');
96
+ expect(runners.map(r => r.name)).toEqual(['pytest-gremlins', 'mutmut']);
97
+ });
98
+
99
+ it('returns a single-element list for extensions with one runner', () => {
100
+ expect(resolveRunners('.go').map(r => r.name)).toEqual(['gomutants']);
101
+ expect(resolveRunners('.ts').map(r => r.name)).toEqual(['Stryker']);
102
+ });
103
+
104
+ it('returns an empty list for unknown extensions', () => {
105
+ expect(resolveRunners('.rb')).toEqual([]);
106
+ });
107
+
108
+ it('handles dot-prefixed extensions', () => {
109
+ const runners = resolveRunners('.py');
110
+ expect(runners.length).toBeGreaterThan(1);
111
+ });
112
+ });
113
+
86
114
  describe('re-exports', () => {
87
115
  it('re-exports all runner classes', () => {
88
116
  expect(StrykerRunner).toBeDefined();
@@ -12,8 +12,10 @@ import {
12
12
  import { detectAITestCharacteristics } from './detect-ai-test';
13
13
  import {
14
14
  resolveRunner,
15
+ resolveRunners,
15
16
  registerAllRunners,
16
17
  MutationRunOutcome,
18
+ MutationRunner,
17
19
  } from './runners';
18
20
 
19
21
  // ── Pre-run auto-registration of all language runners ──
@@ -440,10 +442,14 @@ async function runAllRunners(
440
442
  const fileScores: Record<string, number | null> = {};
441
443
 
442
444
  for (const [, files] of Array.from(groups.entries())) {
443
- const runner = resolveRunner(path.extname(files[0]));
444
- if (!runner) continue;
445
-
446
- if (!await runner.isAvailable()) {
445
+ const ext = path.extname(files[0]);
446
+ // #410 fallback: walk ALL runners for the extension in priority order and
447
+ // use the first one that is actually available. A pure-mutmut environment
448
+ // (no pytest-gremlins) thus still gets mutation testing instead of a
449
+ // silent SKIP — the design contract "gremlins 可用→GremlinsRunner,否则
450
+ // MutmutRunner" is honored here, not by registration order alone.
451
+ const runner = await resolveFirstAvailable(ext);
452
+ if (!runner) {
447
453
  applyNullScores(files, fileScores);
448
454
  continue;
449
455
  }
@@ -455,6 +461,18 @@ async function runAllRunners(
455
461
  return fileScores;
456
462
  }
457
463
 
464
+ /**
465
+ * Pick the first AVAILABLE runner for an extension, honoring registration
466
+ * priority. Returns undefined when no registered runner for the extension is
467
+ * installed on this machine.
468
+ */
469
+ async function resolveFirstAvailable(ext: string): Promise<MutationRunner | undefined> {
470
+ for (const candidate of resolveRunners(ext)) {
471
+ if (await candidate.isAvailable()) return candidate;
472
+ }
473
+ return undefined;
474
+ }
475
+
458
476
  function applyNullScores(files: string[], scores: Record<string, number | null>): void {
459
477
  for (const f of files) scores[f] = null;
460
478
  }
@@ -0,0 +1,238 @@
1
+ // @no-test-required: gremlins wrapper — tested via gremlins-runner.test.ts + pre-push Gate M
2
+ import { spawn, execSync, type ChildProcess } from 'child_process';
3
+ import { dirname } from 'path';
4
+ import type {
5
+ MutationRunner,
6
+ RunMutationOptions,
7
+ MutationRunOutcome,
8
+ MutationRunResult,
9
+ } from './types';
10
+
11
+ /**
12
+ * pytest-gremlins mutation runner for Python files (#410).
13
+ *
14
+ * Why a second Python engine exists: mutmut 3.x is built on fork(), so on
15
+ * Windows it can only run under WSL — a native Windows user had a structural
16
+ * deadlock (installed => `mutmut run` exits 1 => push blocked; uninstalled =>
17
+ * Gate M skips). pytest-gremlins uses mutation *switching* (instrument once,
18
+ * flip an env var per mutant) with no fork and no per-mutant file I/O, making
19
+ * it natively cross-platform. It also matches Gate M's needs: coverage-guided
20
+ * test selection, an incremental content-hash cache, parallel workers and
21
+ * console/HTML/JSON reports.
22
+ *
23
+ * Constraint: pytest-gremlins requires Python >= 3.11. On older interpreters
24
+ * the import probe simply fails and the caller keeps the mutmut path (or
25
+ * SKIPs) — no new hard dependency.
26
+ */
27
+ /**
28
+ * How long to wait after SIGTERM before escalating to SIGKILL. pytest-gremlins
29
+ * runs a whole pytest session per invocation, so a child that ignores SIGTERM
30
+ * (stuck in a C extension) still needs a hard backstop.
31
+ */
32
+ const SIGKILL_ESCALATION_MS = 5000;
33
+
34
+ /** Bound on the one-shot import probe; a hung interpreter must not stall Gate M. */
35
+ const PROBE_TIMEOUT_MS = 10000;
36
+
37
+ /**
38
+ * Exactly-once resolve bookkeeping for a spawned mutation run.
39
+ *
40
+ * A child can report `close`, `error`, a timeout, and the SIGKILL escalation in
41
+ * any interleaving; without a single latched gate the Promise resolves twice
42
+ * (subsequent resolves are silent no-ops, but the *first* one wins, which can
43
+ * be the wrong verdict). `settled` makes the ordering irrelevant. Extracted
44
+ * from `spawnGremlins` so that function stays under the long-function budget.
45
+ *
46
+ * A factory rather than a class: it holds no collaborators to inject, and the
47
+ * SOLID analyzer flags `new X()` as a dependency-inversion smell regardless of
48
+ * whether the type is injectable.
49
+ */
50
+ function createSettleGuard(resolve: (o: MutationRunOutcome) => void) {
51
+ let settled = false;
52
+ let escalationId: NodeJS.Timeout | undefined;
53
+ let timeoutId: NodeJS.Timeout | undefined;
54
+
55
+ const escalateToKill = (child: ChildProcess): void => {
56
+ if (!settled) child.kill('SIGKILL');
57
+ };
58
+
59
+ const guard = {
60
+ timedOut: false,
61
+ /** SIGTERM at `timeoutMs`, escalating to SIGKILL if the child ignores it. */
62
+ armTimeout(child: ChildProcess, timeoutMs: number): void {
63
+ timeoutId = setTimeout(() => {
64
+ guard.timedOut = true;
65
+ child.kill('SIGTERM');
66
+ if (settled) return;
67
+ escalationId = setTimeout(() => escalateToKill(child), SIGKILL_ESCALATION_MS);
68
+ }, timeoutMs);
69
+ },
70
+ settle(outcome: MutationRunOutcome): void {
71
+ if (settled) return;
72
+ settled = true;
73
+ if (timeoutId) clearTimeout(timeoutId);
74
+ if (escalationId) clearTimeout(escalationId);
75
+ resolve(outcome);
76
+ },
77
+ };
78
+ return guard;
79
+ }
80
+
81
+ export class GremlinsRunner implements MutationRunner {
82
+ readonly name = 'pytest-gremlins';
83
+ readonly extensions = ['py'];
84
+
85
+ async isAvailable(): Promise<boolean> {
86
+ try {
87
+ execSync('python -c "import pytest_gremlins"', {
88
+ stdio: 'pipe',
89
+ timeout: PROBE_TIMEOUT_MS,
90
+ });
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ async run(options: RunMutationOptions): Promise<MutationRunOutcome> {
98
+ if (!(await this.isAvailable())) {
99
+ return { report: null, timedOut: false };
100
+ }
101
+
102
+ // Spawning can throw synchronously (e.g. ENOENT on a broken interpreter
103
+ // shim); that must surface as a null report, never as an unhandled
104
+ // rejection that aborts the whole Gate M orchestration.
105
+ try {
106
+ return await this.spawnGremlins(options);
107
+ } catch (err) {
108
+ return {
109
+ report: null,
110
+ timedOut: false,
111
+ error: err instanceof Error ? err.message : String(err),
112
+ };
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Build the argv for `python -m pytest`.
118
+ *
119
+ * `-m pytest` rather than a bare `pytest`: the import probe in
120
+ * `isAvailable()` already proved *this* interpreter carries the plugin, so
121
+ * running through the same interpreter is the only way to be sure the plugin
122
+ * is actually loaded.
123
+ */
124
+ private gremlinsArgs(options: RunMutationOptions): string[] {
125
+ return [
126
+ '-m',
127
+ 'pytest',
128
+ '--gremlins',
129
+ `--gremlin-targets=${buildTargets(options.files)}`,
130
+ '--gremlin-report=console',
131
+ '--gremlin-parallel',
132
+ '--gremlin-cache',
133
+ ];
134
+ }
135
+
136
+ /** Spawn the child and settle exactly once, on close or on error. */
137
+ private spawnGremlins(options: RunMutationOptions): Promise<MutationRunOutcome> {
138
+ return new Promise((resolve) => {
139
+ const guard = createSettleGuard(resolve);
140
+ let child: ChildProcess;
141
+ try {
142
+ child = spawn('python', this.gremlinsArgs(options), {
143
+ stdio: 'pipe',
144
+ cwd: options.cwd,
145
+ });
146
+ } catch (err) {
147
+ guard.settle({
148
+ report: null,
149
+ timedOut: false,
150
+ error: err instanceof Error ? err.message : String(err),
151
+ });
152
+ return;
153
+ }
154
+
155
+ let stdout = '';
156
+ let stderr = '';
157
+ guard.armTimeout(child, options.timeoutMs);
158
+
159
+ child.stdout?.on('data', (data: Buffer) => {
160
+ stdout += data.toString();
161
+ });
162
+ child.stderr?.on('data', (data: Buffer) => {
163
+ stderr += data.toString();
164
+ });
165
+
166
+ child.on('close', (code) => {
167
+ // A missing report is NOT a failure verdict: parse returns null and the
168
+ // orchestrator keeps its non-blocking semantics (same contract as the
169
+ // mutmut runner's null report).
170
+ guard.settle({
171
+ report: parseConsoleReport(stdout),
172
+ timedOut: guard.timedOut,
173
+ error: code !== 0 ? stderr || stdout : undefined,
174
+ });
175
+ });
176
+
177
+ child.on('error', (err) => {
178
+ guard.settle({ report: null, timedOut: guard.timedOut, error: err.message });
179
+ });
180
+ });
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Comma-separated mutate targets, deduplicated, derived from the changed
186
+ * files' directories — `--gremlin-targets` accepts files OR directories, and
187
+ * the directories keep the mutant count bounded to the touched area.
188
+ *
189
+ * Module-level and pure so it is directly testable without instantiating the
190
+ * runner.
191
+ */
192
+ function buildTargets(files: string[]): string {
193
+ const dirs = new Set<string>();
194
+ for (const file of files) {
195
+ const dir = dirname(file);
196
+ dirs.add(dir && dir !== '.' ? dir : file);
197
+ }
198
+ return Array.from(dirs).join(',');
199
+ }
200
+
201
+ /**
202
+ * Parse pytest-gremlins' console report.
203
+ *
204
+ * The report prints two lines (plus a banner):
205
+ * Zapped: 142 gremlins (89%)
206
+ * Survived: 18 gremlins (11%)
207
+ *
208
+ * Returns null when neither line is present — a run that never got to the
209
+ * report (import failure, collection error, timeout) must not be reported as
210
+ * a 0% score, which would be a fabricated verdict. `Zapped` maps to killed,
211
+ * `Survived` to survived; the totals are their sum, matching the mutmut
212
+ * runner's normalization exactly.
213
+ */
214
+ export function parseConsoleReport(stdout: string): MutationRunResult | null {
215
+ const zapped = extractCount(stdout, /Zapped:\s*(\d+)\s*gremlins?/i);
216
+ const survived = extractCount(stdout, /Survived:\s*(\d+)\s*gremlins?/i);
217
+
218
+ if (zapped === null && survived === null) return null;
219
+
220
+ const nrOfKilledMutants = zapped ?? 0;
221
+ const nrOfSurvivedMutants = survived ?? 0;
222
+ const nrOfMutants = nrOfKilledMutants + nrOfSurvivedMutants;
223
+ if (nrOfMutants === 0) return null;
224
+
225
+ return {
226
+ mutationScore: (nrOfKilledMutants / nrOfMutants) * 100,
227
+ nrOfMutants,
228
+ nrOfKilledMutants,
229
+ nrOfSurvivedMutants,
230
+ };
231
+ }
232
+
233
+ function extractCount(text: string, pattern: RegExp): number | null {
234
+ const match = text.match(pattern);
235
+ if (!match?.[1]) return null;
236
+ const value = parseInt(match[1], 10);
237
+ return Number.isNaN(value) ? null : value;
238
+ }
@@ -7,15 +7,17 @@ export type {
7
7
  MutationRunner,
8
8
  } from './types';
9
9
 
10
- export { registerRunner, resolveRunner, runnerRegistry } from './types';
10
+ export { registerRunner, resolveRunner, resolveRunners, runnerRegistry } from './types';
11
11
 
12
12
  export { StrykerRunner } from './stryker-runner';
13
13
  export { MutmutRunner } from './mutmut-runner';
14
+ export { GremlinsRunner } from './gremlins-runner';
14
15
  export { GoMutantRunner } from './go-mutant-runner';
15
16
  export { PitestRunner } from './pitest-runner';
16
17
 
17
18
  import { StrykerRunner } from './stryker-runner';
18
19
  import { MutmutRunner } from './mutmut-runner';
20
+ import { GremlinsRunner } from './gremlins-runner';
19
21
  import { GoMutantRunner } from './go-mutant-runner';
20
22
  import { PitestRunner } from './pitest-runner';
21
23
  import { registerRunner } from './types';
@@ -23,6 +25,12 @@ import { registerRunner } from './types';
23
25
  /** Auto-register all known runners. */
24
26
  export function registerAllRunners(): void {
25
27
  registerRunner(new StrykerRunner());
28
+ // Registration order decides the `py` winner (resolveRunner returns the
29
+ // FIRST runner whose extensions include the ext). pytest-gremlins is
30
+ // registered before mutmut deliberately (#410): it is natively
31
+ // cross-platform, so when both are installed it is the better default —
32
+ // mutmut stays reachable via its own runner on Unix-only setups.
33
+ registerRunner(new GremlinsRunner());
26
34
  registerRunner(new MutmutRunner());
27
35
  registerRunner(new GoMutantRunner());
28
36
  registerRunner(new PitestRunner());