@boyingliu01/xp-gate 0.9.4 → 0.10.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/adapters/python.sh +61 -1
- package/lib/doctor.js +34 -18
- package/lib/shared-phase-constants.js +23 -9
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/gate-m.ts +209 -274
- package/mutation/runners/index.ts +22 -0
- package/mutation/runners/mutmut-runner.ts +220 -0
- package/mutation/runners/stryker-runner.ts +146 -0
- package/mutation/runners/types.ts +99 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +34 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +53 -1
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/__tests__/xp-gate-update.test.ts +1 -11
- package/plugins/opencode/index.ts +0 -1
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +34 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/SKILL.md +53 -1
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/tui-plugin.ts +69 -6
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- 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/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +34 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/sprint-flow/SKILL.md +53 -1
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
|
+
import { writeFileSync, unlinkSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import type {
|
|
5
|
+
MutationRunner,
|
|
6
|
+
RunMutationOptions,
|
|
7
|
+
MutationRunOutcome,
|
|
8
|
+
MutationRunResult,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* mutmut mutation runner for Python files.
|
|
13
|
+
*
|
|
14
|
+
* Uses `mutmut run` to generate mutants and parses emoji progress output.
|
|
15
|
+
* Supports mutmut v3.x (primary) with fallback to v2.x behavior.
|
|
16
|
+
*
|
|
17
|
+
* v3.x changes (GitHub issue #339):
|
|
18
|
+
* - Removed `--paths-to-mutate` CLI flag
|
|
19
|
+
* - Uses `source_paths` in `[tool.mutmut]` section of pyproject.toml
|
|
20
|
+
* - Results stored in SQLite; `mutmut results` outputs per-mutant status lines
|
|
21
|
+
* - `mutmut run` stdout shows emoji progress: 🎉 N 🫥 N ⏰ N 🤔 N 🙁 N
|
|
22
|
+
*/
|
|
23
|
+
export class MutmutRunner implements MutationRunner {
|
|
24
|
+
readonly name = 'mutmut';
|
|
25
|
+
readonly extensions = ['py'];
|
|
26
|
+
|
|
27
|
+
async isAvailable(): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
execSync('mutmut --version', { stdio: 'pipe', timeout: 5000 });
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async run(options: RunMutationOptions): Promise<MutationRunOutcome> {
|
|
37
|
+
// Extract unique directories from file list
|
|
38
|
+
const sourceDirs = this.extractUniqueDirs(options.files);
|
|
39
|
+
|
|
40
|
+
const pyprojectPath = join(options.cwd, 'pyproject.toml');
|
|
41
|
+
const backupPath = join(options.cwd, 'pyproject.toml.xp-gate-backup');
|
|
42
|
+
let hadExisting = false;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (existsSync(pyprojectPath)) {
|
|
46
|
+
hadExisting = true;
|
|
47
|
+
const existing = readFileSync(pyprojectPath, 'utf-8');
|
|
48
|
+
writeFileSync(backupPath, existing, { encoding: 'utf-8' });
|
|
49
|
+
}
|
|
50
|
+
this.writeMutmutConfig(pyprojectPath, sourceDirs);
|
|
51
|
+
|
|
52
|
+
return await this.runMutmut(options);
|
|
53
|
+
} finally {
|
|
54
|
+
try {
|
|
55
|
+
if (hadExisting && existsSync(backupPath)) {
|
|
56
|
+
const backup = readFileSync(backupPath, 'utf-8');
|
|
57
|
+
writeFileSync(pyprojectPath, backup, { encoding: 'utf-8' });
|
|
58
|
+
unlinkSync(backupPath);
|
|
59
|
+
} else if (!hadExisting && existsSync(pyprojectPath)) {
|
|
60
|
+
unlinkSync(pyprojectPath);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Best effort cleanup
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async runMutmut(options: RunMutationOptions): Promise<MutationRunOutcome> {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const child = spawn('mutmut', ['run'], {
|
|
71
|
+
stdio: 'pipe',
|
|
72
|
+
shell: false,
|
|
73
|
+
cwd: options.cwd,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let stderr = '';
|
|
77
|
+
let stdout = '';
|
|
78
|
+
|
|
79
|
+
child.stdout?.on('data', (data: Buffer) => {
|
|
80
|
+
stdout += data.toString();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
child.stderr?.on('data', (data: Buffer) => {
|
|
84
|
+
stderr += data.toString();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const timeoutId = setTimeout(() => {
|
|
88
|
+
child.kill('SIGTERM');
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
if (!child.killed) child.kill('SIGKILL');
|
|
91
|
+
}, 5000);
|
|
92
|
+
}, options.timeoutMs);
|
|
93
|
+
|
|
94
|
+
child.on('close', (code) => {
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
|
|
97
|
+
if (code === null) {
|
|
98
|
+
resolve({ report: null, timedOut: true });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Parse results from mutmut run stdout (v3.x emoji progress)
|
|
103
|
+
const report = this.parseEmojiProgress(stdout);
|
|
104
|
+
resolve({
|
|
105
|
+
report,
|
|
106
|
+
timedOut: false,
|
|
107
|
+
error: code !== 0 ? stderr || stdout : undefined,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
child.on('error', (err) => {
|
|
112
|
+
clearTimeout(timeoutId);
|
|
113
|
+
resolve({ report: null, timedOut: false, error: err.message });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse v3.x emoji progress line from mutmut run stdout.
|
|
120
|
+
*
|
|
121
|
+
* mutmut v3.x prints a progress summary like:
|
|
122
|
+
* 38/38 🎉 9 🫥 29 ⏰ 0 🤔 0 🙁 0 🔇 0 🧙 0
|
|
123
|
+
*
|
|
124
|
+
* Emoji mapping:
|
|
125
|
+
* 🎉 = killed
|
|
126
|
+
* 🫥 = not checked (untested)
|
|
127
|
+
* ⏰ = timeout
|
|
128
|
+
* 🤔 = suspicious
|
|
129
|
+
* 🙁 = survived
|
|
130
|
+
* 🔇 = unknown (counted as survived)
|
|
131
|
+
* 🧙 = unknown (counted as survived)
|
|
132
|
+
*/
|
|
133
|
+
private parseEmojiProgress(stdout: string): MutationRunResult | null {
|
|
134
|
+
// Find the last progress line (contains 🎉)
|
|
135
|
+
const lines = stdout.split('\n');
|
|
136
|
+
let progressLine = '';
|
|
137
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
138
|
+
if (lines[i].includes('🎉')) {
|
|
139
|
+
progressLine = lines[i];
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!progressLine) return null;
|
|
144
|
+
|
|
145
|
+
// Parse emoji counts: 🎉 N 🫥 N ⏰ N 🤔 N 🙁 N 🔇 N 🧙 N
|
|
146
|
+
const killed = this.extractEmojiCount(progressLine, '🎉');
|
|
147
|
+
const untested = this.extractEmojiCount(progressLine, '🫥');
|
|
148
|
+
const timeout = this.extractEmojiCount(progressLine, '⏰');
|
|
149
|
+
const suspicious = this.extractEmojiCount(progressLine, '🤔');
|
|
150
|
+
const survived = this.extractEmojiCount(progressLine, '🙁');
|
|
151
|
+
const unknown1 = this.extractEmojiCount(progressLine, '🔇');
|
|
152
|
+
const unknown2 = this.extractEmojiCount(progressLine, '🧙');
|
|
153
|
+
|
|
154
|
+
const nrOfKilledMutants = killed;
|
|
155
|
+
const nrOfSurvivedMutants =
|
|
156
|
+
survived + timeout + suspicious + untested + unknown1 + unknown2;
|
|
157
|
+
const nrOfMutants = nrOfKilledMutants + nrOfSurvivedMutants;
|
|
158
|
+
|
|
159
|
+
if (nrOfMutants === 0) return null;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
mutationScore: (nrOfKilledMutants / nrOfMutants) * 100,
|
|
163
|
+
nrOfMutants,
|
|
164
|
+
nrOfKilledMutants,
|
|
165
|
+
nrOfSurvivedMutants,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Extract count after an emoji in a progress line.
|
|
171
|
+
* e.g., "🎉 9" → 9
|
|
172
|
+
*/
|
|
173
|
+
private extractEmojiCount(line: string, emoji: string): number {
|
|
174
|
+
const escapedEmoji = emoji.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
175
|
+
const match = line.match(new RegExp(`${escapedEmoji}\\s*(\\d+)`));
|
|
176
|
+
return parseInt(match?.[1] ?? '0', 10);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extract unique directories from file paths.
|
|
181
|
+
* e.g., ['src/foo.py', 'src/bar.py', 'tests/baz.py'] → ['src', 'tests']
|
|
182
|
+
*/
|
|
183
|
+
private extractUniqueDirs(files: string[]): string[] {
|
|
184
|
+
const dirs = new Set<string>();
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const dir = dirname(file);
|
|
187
|
+
if (dir && dir !== '.') {
|
|
188
|
+
dirs.add(dir);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// If no directories found, use current directory
|
|
192
|
+
return dirs.size > 0 ? Array.from(dirs) : ['.'];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private writeMutmutConfig(pyprojectPath: string, sourceDirs: string[]): void {
|
|
196
|
+
const sourcePaths = sourceDirs.map((d) => `"${d}"`).join(', ');
|
|
197
|
+
const sourcePathsLine = `source_paths = [${sourcePaths}]`;
|
|
198
|
+
|
|
199
|
+
let content = '';
|
|
200
|
+
if (existsSync(pyprojectPath)) {
|
|
201
|
+
content = readFileSync(pyprojectPath, 'utf-8');
|
|
202
|
+
if (content.includes('[tool.mutmut]')) {
|
|
203
|
+
const sourcePathsRegex = /source_paths\s*=\s*\[.*?\]/;
|
|
204
|
+
if (sourcePathsRegex.test(content)) {
|
|
205
|
+
content = content.replace(sourcePathsRegex, sourcePathsLine);
|
|
206
|
+
} else {
|
|
207
|
+
content = content.replace(
|
|
208
|
+
'[tool.mutmut]',
|
|
209
|
+
`[tool.mutmut]\n${sourcePathsLine}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
content = content.trimEnd() + `\n\n[tool.mutmut]\n${sourcePathsLine}\n`;
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
content = `[tool.mutmut]\n${sourcePathsLine}\n`;
|
|
217
|
+
}
|
|
218
|
+
writeFileSync(pyprojectPath, content, { encoding: 'utf-8' });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import type {
|
|
5
|
+
MutationRunner,
|
|
6
|
+
RunMutationOptions,
|
|
7
|
+
MutationRunOutcome,
|
|
8
|
+
MutationRunResult,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
const STRYKER_REPORT_PATH = '.stryker-report.json';
|
|
12
|
+
const STRYKER_PREPUSH_CONFIG = 'stryker.prepush.conf.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stryker mutation runner for TypeScript files.
|
|
16
|
+
* Extracted from gate-m.ts to follow the LangAdapter pattern.
|
|
17
|
+
*/
|
|
18
|
+
export class StrykerRunner implements MutationRunner {
|
|
19
|
+
readonly name = 'Stryker';
|
|
20
|
+
readonly extensions = ['ts', 'tsx'];
|
|
21
|
+
|
|
22
|
+
async isAvailable(): Promise<boolean> {
|
|
23
|
+
try {
|
|
24
|
+
const { spawnSync } = await import('child_process');
|
|
25
|
+
const result = spawnSync('npx', ['stryker', '--version'], {
|
|
26
|
+
stdio: 'pipe',
|
|
27
|
+
shell: false,
|
|
28
|
+
timeout: 5000,
|
|
29
|
+
});
|
|
30
|
+
return result.status !== null && result.status === 0;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async run(options: RunMutationOptions): Promise<MutationRunOutcome> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const args = [
|
|
39
|
+
'stryker',
|
|
40
|
+
'run',
|
|
41
|
+
'--config',
|
|
42
|
+
this.resolveConfig(options.cwd),
|
|
43
|
+
...options.files.flatMap(f => ['--mutate', f]),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const child = spawn('npx', args, {
|
|
47
|
+
stdio: 'pipe',
|
|
48
|
+
shell: false,
|
|
49
|
+
cwd: options.cwd,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
let stderr = '';
|
|
53
|
+
let stdout = '';
|
|
54
|
+
|
|
55
|
+
child.stdout?.on('data', (data: Buffer) => {
|
|
56
|
+
stdout += data.toString();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
child.stderr?.on('data', (data: Buffer) => {
|
|
60
|
+
stderr += data.toString();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const timeoutId = setTimeout(() => {
|
|
64
|
+
child.kill('SIGTERM');
|
|
65
|
+
setTimeout(() => {
|
|
66
|
+
if (!child.killed) child.kill('SIGKILL');
|
|
67
|
+
}, 5000);
|
|
68
|
+
}, options.timeoutMs);
|
|
69
|
+
|
|
70
|
+
child.on('close', (code) => {
|
|
71
|
+
clearTimeout(timeoutId);
|
|
72
|
+
|
|
73
|
+
if (code === null) {
|
|
74
|
+
resolve({ report: null, timedOut: true });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const report = this.parseReport(options.cwd);
|
|
79
|
+
resolve({
|
|
80
|
+
report,
|
|
81
|
+
timedOut: false,
|
|
82
|
+
error: code !== 0 ? stderr || stdout : undefined,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
child.on('error', (err) => {
|
|
87
|
+
clearTimeout(timeoutId);
|
|
88
|
+
resolve({ report: null, timedOut: false, error: err.message });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private resolveConfig(cwd: string): string {
|
|
94
|
+
return join(cwd, STRYKER_PREPUSH_CONFIG);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private parseReport(cwd: string): MutationRunResult | null {
|
|
98
|
+
try {
|
|
99
|
+
const content = readFileSync(join(cwd, STRYKER_REPORT_PATH), 'utf-8');
|
|
100
|
+
return this.parseReportObject(JSON.parse(content));
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private parseReportObject(parsed: Record<string, unknown>): MutationRunResult | null {
|
|
107
|
+
const mutationScore = asNumber(parsed.mutationScore);
|
|
108
|
+
const nrOfMutants = asNumber(parsed.nrOfMutants);
|
|
109
|
+
const nrOfKilledMutants = asNumber(parsed.nrOfKilledMutants);
|
|
110
|
+
const nrOfSurvivedMutants = asNumber(parsed.nrOfSurvivedMutants);
|
|
111
|
+
|
|
112
|
+
if (isNaN(mutationScore) || isNaN(nrOfMutants)) return null;
|
|
113
|
+
|
|
114
|
+
const result: MutationRunResult = {
|
|
115
|
+
mutationScore,
|
|
116
|
+
nrOfMutants,
|
|
117
|
+
nrOfKilledMutants,
|
|
118
|
+
nrOfSurvivedMutants,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (parsed.files && typeof parsed.files === 'object') {
|
|
122
|
+
result.files = parseFilesObject(parsed.files as Record<string, Record<string, unknown>>);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function asNumber(value: unknown, fallback = 0): number {
|
|
130
|
+
return typeof value === 'number' ? value : fallback;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseFilesObject(
|
|
134
|
+
filesObj: Record<string, Record<string, unknown>>
|
|
135
|
+
): Record<string, { mutationScore: number; nrOfMutants: number; nrOfKilledMutants: number; nrOfSurvivedMutants: number }> {
|
|
136
|
+
const files: Record<string, { mutationScore: number; nrOfMutants: number; nrOfKilledMutants: number; nrOfSurvivedMutants: number }> = {};
|
|
137
|
+
for (const [file, data] of Object.entries(filesObj)) {
|
|
138
|
+
files[file] = {
|
|
139
|
+
mutationScore: asNumber(data.mutationScore),
|
|
140
|
+
nrOfMutants: asNumber(data.nrOfMutants),
|
|
141
|
+
nrOfKilledMutants: asNumber(data.nrOfKilledMutants),
|
|
142
|
+
nrOfSurvivedMutants: asNumber(data.nrOfSurvivedMutants),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return files;
|
|
146
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-agnostic mutation runner interface.
|
|
3
|
+
*
|
|
4
|
+
* Each language-specific runner (Stryker for TypeScript, mutmut for Python)
|
|
5
|
+
* implements this interface so gate-m.ts can route by file extension without
|
|
6
|
+
* knowing the underlying tool.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Normalized mutation score — all runners emit this shape. */
|
|
10
|
+
export interface MutationFileReport {
|
|
11
|
+
/** Score as percentage (0–100). */
|
|
12
|
+
mutationScore: number;
|
|
13
|
+
/** Total number of mutants generated for this file. */
|
|
14
|
+
nrOfMutants: number;
|
|
15
|
+
/** Mutants killed by the test suite. */
|
|
16
|
+
nrOfKilledMutants: number;
|
|
17
|
+
/** Mutants that survived. */
|
|
18
|
+
nrOfSurvivedMutants: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Aggregated result from a single runner invocation. */
|
|
22
|
+
export interface MutationRunResult {
|
|
23
|
+
/** Top-level normalized mutation score (0–100). */
|
|
24
|
+
mutationScore: number;
|
|
25
|
+
/** Total mutants across all processed files. */
|
|
26
|
+
nrOfMutants: number;
|
|
27
|
+
/** Total killed mutants. */
|
|
28
|
+
nrOfKilledMutants: number;
|
|
29
|
+
/** Total survived mutants. */
|
|
30
|
+
nrOfSurvivedMutants: number;
|
|
31
|
+
/** Per-file breakdown (optional; runner may not support it). */
|
|
32
|
+
files?: Record<string, MutationFileReport>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Outcome of invoking a mutation runner. */
|
|
36
|
+
export interface MutationRunOutcome {
|
|
37
|
+
/** Parsed report data. null if the run failed or timed out. */
|
|
38
|
+
report: MutationRunResult | null;
|
|
39
|
+
/** Whether the runner exceeded its timeout. */
|
|
40
|
+
timedOut: boolean;
|
|
41
|
+
/** Error message (if any). */
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parameters passed to all runners. */
|
|
46
|
+
export interface RunMutationOptions {
|
|
47
|
+
/** Source files to mutate (relative paths, no test files). */
|
|
48
|
+
files: string[];
|
|
49
|
+
/** Timeout in milliseconds. */
|
|
50
|
+
timeoutMs: number;
|
|
51
|
+
/** Project root directory (for config file resolution). */
|
|
52
|
+
cwd: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Language-specific mutation runner.
|
|
57
|
+
*
|
|
58
|
+
* Implement this interface for each tool (Stryker, mutmut, etc.).
|
|
59
|
+
* The runner is responsible for:
|
|
60
|
+
* 1. Invoking the mutation tool with the correct CLI args.
|
|
61
|
+
* 2. Parsing tool-specific output into a normalized MutationRunResult.
|
|
62
|
+
* 3. Enforcing the timeout and returning a MutationRunOutcome.
|
|
63
|
+
*
|
|
64
|
+
* Do NOT include threshold logic, baseline comparison, or file filtering here —
|
|
65
|
+
* those are the responsibility of gate-m.ts (the orchestrator).
|
|
66
|
+
*/
|
|
67
|
+
export interface MutationRunner {
|
|
68
|
+
/** Human-readable name (e.g. "Stryker", "mutmut"). */
|
|
69
|
+
readonly name: string;
|
|
70
|
+
|
|
71
|
+
/** File extensions this runner handles (without dot, e.g. ["ts", "tsx"]). */
|
|
72
|
+
readonly extensions: string[];
|
|
73
|
+
|
|
74
|
+
/** Whether the tool is available on the system (quick check). */
|
|
75
|
+
isAvailable(): Promise<boolean>;
|
|
76
|
+
|
|
77
|
+
/** Run mutation testing on the given files. */
|
|
78
|
+
run(options: RunMutationOptions): Promise<MutationRunOutcome>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Registry of runners, keyed by language name. */
|
|
82
|
+
export const runnerRegistry = new Map<string, MutationRunner>();
|
|
83
|
+
|
|
84
|
+
/** Register a runner so it can be resolved by file extension. */
|
|
85
|
+
export function registerRunner(runner: MutationRunner): void {
|
|
86
|
+
runnerRegistry.set(runner.name, runner);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the correct runner for a given file extension.
|
|
91
|
+
* Returns undefined if no runner supports this extension.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveRunner(ext: string): MutationRunner | undefined {
|
|
94
|
+
const normalized = ext.replace(/^\./, '');
|
|
95
|
+
for (const runner of Array.from(runnerRegistry.values())) {
|
|
96
|
+
if (runner.extensions.includes(normalized)) return runner;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 10 quality gates (Gate 0-9), Sprint Flow (11 phases), and Delphi multi-expert review (>=90% consensus).",
|
|
6
6
|
"author": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-06-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-06-22
|
|
4
|
+
**Commit:** 984d568
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.10.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, 3 experts from ≥2 providers, domestic models only). Supports design + code-walkthrough modes.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: delphi-review
|
|
3
3
|
description: "Use when asked to review a design, plan, or architecture; before implementation starts; or when multi-expert consensus is needed. Triggers: 'review this design', '评审这个需求', 'design review', '多专家评审', 'consensus review', 'code walkthrough', 'push review', 'architecture review', 'PR review', or any request for multi-expert evaluation of requirements, design docs, or PRs."
|
|
4
|
+
auto_continue: true
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# Delphi Consensus Review
|
|
@@ -109,6 +110,39 @@ Phase 0: 准备 → Round 1: 匿名独立评审 → 共识检查
|
|
|
109
110
|
|
|
110
111
|
**Automatic re-review**: 对于常见可控问题(措辞模糊、AC 缺失、格式问题),subagent 应自行修复后自动重评审,无需等待用户。
|
|
111
112
|
|
|
113
|
+
### ⭐ 自动延续规则(MANDATORY — 防止流程卡住)
|
|
114
|
+
|
|
115
|
+
当 Delphi Review 启动多轮评审(Round 2+)时,**orchestrator 必须自动延续流程,不得等待用户输入**。
|
|
116
|
+
|
|
117
|
+
**触发条件**:
|
|
118
|
+
- Round N 完成,但未达到终止条件(100% approved + ≥90% consensus + 所有 Critical/Major 已处理)
|
|
119
|
+
- 存在待处理的背景任务(subagent dispatched tasks)
|
|
120
|
+
|
|
121
|
+
**自动延续动作**:
|
|
122
|
+
1. **收集背景任务结果**:等待 `<system-reminder>` 通知后,立即调用 `background_output(task_id="bg_...")` 获取所有 subagent 输出
|
|
123
|
+
2. **合成 Round N 总结**:汇总专家意见、共识度、待处理问题
|
|
124
|
+
3. **自动启动 Round N+1**:立即 dispatch 新一轮 subagent 任务,携带上一轮总结作为上下文
|
|
125
|
+
4. **循环直至终止**:重复步骤 1-3,直到达到终止条件
|
|
126
|
+
|
|
127
|
+
**终止条件**(满足全部):
|
|
128
|
+
- ✅ 所有专家状态 = APPROVED
|
|
129
|
+
- ✅ 共识度 ≥ 90%
|
|
130
|
+
- ✅ 所有 Critical 级别问题已解决
|
|
131
|
+
- ✅ 所有 Major 级别问题已处理(或已记录为已知问题)
|
|
132
|
+
|
|
133
|
+
**例外情况**(直接输出,不进入下一轮):
|
|
134
|
+
- Round 1 即达到 100% approved + 100% consensus → 直接输出最终报告
|
|
135
|
+
- 已达最大轮数(5 轮)→ 输出"未达成共识"报告,标记为 PROCESS_BLOCK
|
|
136
|
+
|
|
137
|
+
**禁止行为**:
|
|
138
|
+
- ❌ 询问用户"要继续吗?"
|
|
139
|
+
- ❌ 等待用户手动触发 Round N+1
|
|
140
|
+
- ❌ 在未达到终止条件时停止流程
|
|
141
|
+
|
|
142
|
+
**错误处理**:
|
|
143
|
+
- 背景任务超时(>10min)→ 标记为 TIMEOUT,输出部分结果并终止
|
|
144
|
+
- 背景任务失败(subagent 错误)→ 重试 1 次,仍失败则输出错误报告并终止
|
|
145
|
+
|
|
112
146
|
---
|
|
113
147
|
|
|
114
148
|
## 修复与重新评审
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-06-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-06-22
|
|
4
|
+
**Commit:** 984d568
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.10.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**11-phase** development pipeline: ISOLATE → AUTO-ESTIMATE → THINK → PLAN → BUILD → REVIEW → USER ACCEPTANCE → FEEDBACK → SHIP → LAND → CLEANUP. Phase 2 default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE in Phase 1: design must pass Delphi review (≥90% consensus) before any coding.
|
|
@@ -408,6 +408,58 @@ Sprint Flow: ISOLATE → AUTO-ESTIMATE → THINK → PLAN → BUILD → REVIEW
|
|
|
408
408
|
|
|
409
409
|
---
|
|
410
410
|
|
|
411
|
+
### ⭐ Phase State Persistence(阶段状态持久化 — MANDATORY)
|
|
412
|
+
|
|
413
|
+
**编排器必须在每个 Phase 完成后更新 `.sprint-state/sprint-state.json`**:
|
|
414
|
+
|
|
415
|
+
1. **Phase 完成后立即更新**(每个 Phase 结束前):
|
|
416
|
+
- `phase`: 更新为当前 Phase 编号(如 `0`, `1`, `2`...)
|
|
417
|
+
- `status`: 更新为 `"completed"`(已完成 Phase)
|
|
418
|
+
- `phase_history`: 追加新条目
|
|
419
|
+
|
|
420
|
+
2. **`phase_history` 数组条目 schema**:
|
|
421
|
+
```json
|
|
422
|
+
{
|
|
423
|
+
"phase": 0,
|
|
424
|
+
"phase_name": "THINK",
|
|
425
|
+
"status": "completed",
|
|
426
|
+
"timestamp": "2026-06-20T10:30:00Z"
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
3. **检查点**:
|
|
431
|
+
- `--status` 参数读取 `sprint-state.json` 并渲染进度看板
|
|
432
|
+
- TUI panel 显示当前 Phase 和历史
|
|
433
|
+
- `--resume-from` 校验 `phase_history` 中的最后完成 Phase
|
|
434
|
+
|
|
435
|
+
4. **完整 sprint-state.json 示例**:
|
|
436
|
+
```json
|
|
437
|
+
{
|
|
438
|
+
"id": "sprint-2026-06-20-01",
|
|
439
|
+
"phase": 2,
|
|
440
|
+
"status": "in_progress",
|
|
441
|
+
"phase_history": [
|
|
442
|
+
{"phase": -1, "phase_name": "ISOLATE", "status": "completed", "timestamp": "2026-06-20T10:00:00Z"},
|
|
443
|
+
{"phase": -0.5, "phase_name": "AUTO-ESTIMATE", "status": "completed", "timestamp": "2026-06-20T10:05:00Z"},
|
|
444
|
+
{"phase": 0, "phase_name": "THINK", "status": "completed", "timestamp": "2026-06-20T10:15:00Z"},
|
|
445
|
+
{"phase": 1, "phase_name": "PLAN", "status": "completed", "timestamp": "2026-06-20T10:30:00Z"}
|
|
446
|
+
],
|
|
447
|
+
"isolation": {
|
|
448
|
+
"worktree_path": "/home/boyingliu01/projects/xp-gate/.worktrees/sprint/sprint-2026-06-20-01",
|
|
449
|
+
"branch": "sprint/2026-06-20-01"
|
|
450
|
+
},
|
|
451
|
+
"outputs": {
|
|
452
|
+
"pain_document": "phase-outputs/phase-0-summary.md",
|
|
453
|
+
"specification": "phase-outputs/specification.yaml"
|
|
454
|
+
},
|
|
455
|
+
"metrics": {
|
|
456
|
+
"coverage_pct": 85.5
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
411
463
|
## 使用示例
|
|
412
464
|
|
|
413
465
|
| 场景 | 命令 | 说明 |
|
|
@@ -447,7 +499,7 @@ See [Output Contract](#output-contract) below for the canonical machine-readable
|
|
|
447
499
|
|
|
448
500
|
**Phase Summary** (每个 Phase 必须输出 YAML frontmatter): `phase/N`, `phase_name`, `status`, `outputs[]`, `decisions[]`, `next_phase_context` + markdown body (≤50 lines)
|
|
449
501
|
|
|
450
|
-
**Sprint State JSON**: `{id, phase, status, isolation {worktree_path, branch}, outputs, metrics}` — 存储于 `.sprint-state/sprint-state.json`
|
|
502
|
+
**Sprint State JSON**: `{id, phase, status, phase_history[], isolation {worktree_path, branch}, outputs, metrics}` — 存储于 `.sprint-state/sprint-state.json`
|
|
451
503
|
|
|
452
504
|
**Final User-Facing Output**: Phase/status, file paths, validation results, next user decision, PR URL or cleanup report
|
|
453
505
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-06-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-06-22
|
|
4
|
+
**Commit:** 984d568
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.10.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
|
@@ -15,7 +15,6 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node
|
|
|
15
15
|
import { join } from "node:path"
|
|
16
16
|
import { tmpdir, homedir } from "node:os"
|
|
17
17
|
import { execSync, spawn } from "node:child_process"
|
|
18
|
-
import { EventEmitter } from "node:events"
|
|
19
18
|
|
|
20
19
|
// ── Pure function: semverLt ──
|
|
21
20
|
|
|
@@ -189,16 +188,6 @@ function readXpGateConfig(): { autoUpgrade?: boolean } | null {
|
|
|
189
188
|
}
|
|
190
189
|
}
|
|
191
190
|
|
|
192
|
-
/**
|
|
193
|
-
* Wait for a child process to close and resolve with exit code.
|
|
194
|
-
*/
|
|
195
|
-
function waitForSpawn(child: ReturnType<typeof spawn>): Promise<number> {
|
|
196
|
-
return new Promise((resolve, reject) => {
|
|
197
|
-
child.on("close", (code) => resolve(code ?? -1))
|
|
198
|
-
child.on("error", (err) => reject(err))
|
|
199
|
-
})
|
|
200
|
-
}
|
|
201
|
-
|
|
202
191
|
// ── Tests ──
|
|
203
192
|
|
|
204
193
|
void describe("semverLt", () => {
|
|
@@ -285,6 +274,7 @@ void describe("checkXpGateUpdate — cache & upgrade", () => {
|
|
|
285
274
|
})
|
|
286
275
|
})
|
|
287
276
|
|
|
277
|
+
|
|
288
278
|
// ── UPG-002: spawn-based upgrade tests ──
|
|
289
279
|
|
|
290
280
|
void describe("checkXpGateUpdate — spawn (UPG-002)", () => {
|