@boyingliu01/xp-gate 0.23.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.
- package/adapter-common.sh +51 -2
- package/adapters/python.sh +10 -2
- package/bin/xp-gate.js +15 -1
- package/hooks/adapter-common.sh +51 -2
- package/hooks/lib/coverage-total.sh +20 -0
- package/hooks/lib/python-test-selection.sh +132 -0
- package/hooks/pre-commit +100 -11
- package/lib/__tests__/delphi-setup.test.js +203 -0
- package/lib/__tests__/doctor-delphi.test.js +244 -0
- package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
- package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
- package/lib/__tests__/qoder-delphi-agents.test.js +31 -0
- package/lib/delphi-setup.js +155 -0
- package/lib/doctor.js +269 -1
- package/lib/init.js +49 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/phase-transition.js +61 -0
- package/lib/sprint-state-manager.js +86 -39
- package/lib/sprint-state-migrator.js +60 -22
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +167 -0
- package/mutation/__tests__/mutmut-runner.test.ts +20 -22
- package/mutation/__tests__/runners-index.test.ts +31 -3
- package/mutation/gate-m.ts +22 -4
- package/mutation/runners/gremlins-runner.ts +238 -0
- package/mutation/runners/index.ts +9 -1
- package/mutation/runners/mutmut-runner.ts +18 -12
- package/mutation/runners/types.ts +20 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +43 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +43 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +43 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +43 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/delphi-external-review.cjs +166 -14
- package/skills/delphi-review/.delphi-config.json.example +24 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +43 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -57,18 +57,9 @@ function migrateState(state, stateDir) {
|
|
|
57
57
|
if (!state.phase_history) { state.phase_history = []; }
|
|
58
58
|
if (!state.isolation) { state.isolation = { worktree_path: null, branch: null }; }
|
|
59
59
|
|
|
60
|
-
state.phase_history = state.phase_history.map(entry =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (mapped) {
|
|
64
|
-
warnings.push(`Migrated legacy phase ${entry.phase} → ${mapped.phase} (${mapped.phase_name})`);
|
|
65
|
-
return { ...entry, phase: mapped.phase, phase_name: mapped.phase_name };
|
|
66
|
-
}
|
|
67
|
-
if (!entry.phase_name && entry.phase >= 1 && entry.phase <= 6) {
|
|
68
|
-
entry.phase_name = PHASE_NAMES[entry.phase - 1];
|
|
69
|
-
}
|
|
70
|
-
return entry;
|
|
71
|
-
});
|
|
60
|
+
state.phase_history = state.phase_history.map(entry =>
|
|
61
|
+
normalizePhaseEntry(entry, warnings),
|
|
62
|
+
);
|
|
72
63
|
|
|
73
64
|
const currentPhaseNum = String(state.phase);
|
|
74
65
|
const currentMapped = LEGACY_PHASE_MAP[currentPhaseNum];
|
|
@@ -91,6 +82,47 @@ function migrateState(state, stateDir) {
|
|
|
91
82
|
return state;
|
|
92
83
|
}
|
|
93
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Migrate and normalize a single phase_history entry.
|
|
87
|
+
*
|
|
88
|
+
* Two passes, in this order:
|
|
89
|
+
* 1. legacy numbering → current numbering via LEGACY_PHASE_MAP;
|
|
90
|
+
* 2. field normalization: phase 1 historically carried `name` while phase 2+
|
|
91
|
+
* carried `phase_name` (transitionPhase always writes `phase_name`).
|
|
92
|
+
* Normalize to `phase_name` as the single canonical field, in BOTH
|
|
93
|
+
* directions so a half-migrated file converges. The legacy `name` is kept
|
|
94
|
+
* (not deleted) so any reader still consulting it keeps working.
|
|
95
|
+
*
|
|
96
|
+
* Ordering matters: the normalization must run for every entry, including those
|
|
97
|
+
* the legacy remap just rewrote — LEGACY_PHASE_MAP covers every phase 1-6, so
|
|
98
|
+
* gating normalization on "not remapped" would skip exactly the legacy files it
|
|
99
|
+
* exists for. (#507 left such a file: phase 1 with `name: 'PREP'`, phase 2+
|
|
100
|
+
* with `phase_name`.)
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} entry - One phase_history entry (possibly legacy-shaped)
|
|
103
|
+
* @param {string[]} warnings - Accumulator for human-readable migration notes
|
|
104
|
+
* @returns {Object} The normalized entry
|
|
105
|
+
*/
|
|
106
|
+
function normalizePhaseEntry(entry, warnings) {
|
|
107
|
+
const phaseNum = String(entry.phase);
|
|
108
|
+
const mapped = LEGACY_PHASE_MAP[phaseNum];
|
|
109
|
+
if (mapped) {
|
|
110
|
+
warnings.push(`Migrated legacy phase ${entry.phase} → ${mapped.phase} (${mapped.phase_name})`);
|
|
111
|
+
entry = { ...entry, phase: mapped.phase, phase_name: mapped.phase_name };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!entry.phase_name && entry.name) {
|
|
115
|
+
entry.phase_name = entry.name;
|
|
116
|
+
}
|
|
117
|
+
if (!entry.phase_name && entry.phase >= 1 && entry.phase <= 6) {
|
|
118
|
+
entry.phase_name = PHASE_NAMES[entry.phase - 1];
|
|
119
|
+
}
|
|
120
|
+
if (!entry.name && entry.phase_name) {
|
|
121
|
+
entry.name = entry.phase_name;
|
|
122
|
+
}
|
|
123
|
+
return entry;
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
/**
|
|
95
127
|
* Create initial sprint state for new sprints.
|
|
96
128
|
*
|
|
@@ -120,17 +152,23 @@ function createInitialState(projectRoot) {
|
|
|
120
152
|
* @param {string} stateDir - Path to .sprint-state directory
|
|
121
153
|
*/
|
|
122
154
|
function logMigrationWarnings(warnings, stateDir) {
|
|
123
|
-
|
|
124
|
-
fs.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const warningsFile = path.join(stateDir, 'migration-warnings.json');
|
|
128
|
-
const existing = fs.existsSync(warningsFile)
|
|
129
|
-
? JSON.parse(fs.readFileSync(warningsFile, 'utf8'))
|
|
130
|
-
: [];
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(stateDir)) {
|
|
157
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
158
|
+
}
|
|
131
159
|
|
|
132
|
-
|
|
133
|
-
|
|
160
|
+
const warningsFile = path.join(stateDir, 'migration-warnings.json');
|
|
161
|
+
const existing = fs.existsSync(warningsFile)
|
|
162
|
+
? JSON.parse(fs.readFileSync(warningsFile, 'utf8'))
|
|
163
|
+
: [];
|
|
164
|
+
|
|
165
|
+
existing.push({ timestamp: new Date().toISOString(), warnings });
|
|
166
|
+
fs.writeFileSync(warningsFile, JSON.stringify(existing, null, 2), 'utf8');
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Migration must still return the migrated state; a failure to persist the
|
|
169
|
+
// warning log is reported to stderr and never thrown.
|
|
170
|
+
console.warn(`[WARN] could not write migration warnings: ${err.message}`);
|
|
171
|
+
}
|
|
134
172
|
}
|
|
135
173
|
|
|
136
174
|
/**
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-14
|
|
4
|
+
**Commit:** a757d625
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.25.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-14
|
|
4
|
+
**Commit:** a757d625
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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(
|
|
268
|
-
expect(spawn).
|
|
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
|
|
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('
|
|
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
|
|
36
|
+
it('registerAllRunners() registers all 5 runners', () => {
|
|
36
37
|
registerAllRunners();
|
|
37
38
|
|
|
38
|
-
expect(runnerRegistry.size).toBe(
|
|
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', '
|
|
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();
|
package/mutation/gate-m.ts
CHANGED
|
@@ -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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
}
|