@boyingliu01/xp-gate 0.11.3 → 0.11.4

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.
@@ -13,7 +13,6 @@ import { detectAITestCharacteristics } from './detect-ai-test';
13
13
  import {
14
14
  resolveRunner,
15
15
  registerAllRunners,
16
- RunMutationOptions,
17
16
  MutationRunOutcome,
18
17
  } from './runners';
19
18
 
@@ -81,6 +80,10 @@ function filterSourceFiles(files: string[]): string[] {
81
80
  const ext = path.extname(file);
82
81
  // Skip test files
83
82
  if (file.includes('.test.') || file.includes('_test.')) return false;
83
+ // Skip Java/Kotlin test files: FooTest.java, FooSpec.kt, etc.
84
+ if (/[A-Z]Test\.(java|kt|kts)$/.test(file)) return false;
85
+ if (/[A-Z]Tests\.(java|kt|kts)$/.test(file)) return false;
86
+ if (/[A-Z](IT|Spec)\.(java|kt|kts)$/.test(file)) return false;
84
87
  // Skip declaration files
85
88
  if (file.endsWith('.d.ts')) return false;
86
89
  // Skip adapter files
@@ -100,36 +103,85 @@ async function fileExists(filePath: string): Promise<boolean> {
100
103
  }
101
104
  }
102
105
 
106
+ async function findFirstExist(candidates: string[]): Promise<string | null> {
107
+ for (const c of candidates) {
108
+ if (await fileExists(c)) return c;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ async function findTsTestFile(sourceFile: string, dir: string, baseName: string): Promise<string | null> {
114
+ return findFirstExist([
115
+ sourceFile.replace(/\.tsx?$/, '.test.ts'),
116
+ path.join(dir, '__tests__', `${baseName}.test.ts`),
117
+ ]);
118
+ }
119
+
120
+ async function findPyTestFile(dir: string, baseName: string): Promise<string | null> {
121
+ return findFirstExist([
122
+ '', // placeholder for the side-by-side _test.py case (handled inline below)
123
+ path.join('tests', `test_${baseName}.py`),
124
+ path.join(dir, '__tests__', `test_${baseName}.py`),
125
+ ]);
126
+ }
127
+
128
+ async function findJavaTestFile(dir: string, baseName: string): Promise<string | null> {
129
+ const dirs = [
130
+ dir.replace(/\/src\/main\//, '/src/test/'),
131
+ dir.replace(/^src\//, 'src/test/'),
132
+ ];
133
+ const suffixes = ['Test.java', 'Tests.java', 'IT.java'];
134
+ for (const d of dirs) {
135
+ for (const s of suffixes) {
136
+ const c = path.join(d, `${baseName}${s}`);
137
+ if (await fileExists(c)) return c;
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ async function findKotlinTestFile(dir: string, baseName: string): Promise<string | null> {
144
+ const dirs = [
145
+ dir.replace(/\/src\/main\//, '/src/test/'),
146
+ dir.replace(/^src\//, 'src/test/'),
147
+ ];
148
+ const suffixes = ['Test.kt', 'Tests.kt', 'Spec.kt'];
149
+ for (const d of dirs) {
150
+ for (const s of suffixes) {
151
+ const c = path.join(d, `${baseName}${s}`);
152
+ if (await fileExists(c)) return c;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
103
158
  async function findTestFileForSource(sourceFile: string): Promise<string | null> {
104
159
  const ext = path.extname(sourceFile);
105
160
  const baseName = path.basename(sourceFile, ext);
106
161
  const dir = path.dirname(sourceFile);
107
162
 
108
163
  if (ext === '.ts' || ext === '.tsx') {
109
- // TypeScript: foo.ts → foo.test.ts
110
- const testFile1 = sourceFile.replace(/\.tsx?$/, '.test.ts');
111
- if (await fileExists(testFile1)) return testFile1;
112
-
113
- const testFile2 = path.join(dir, '__tests__', `${baseName}.test.ts`);
114
- if (await fileExists(testFile2)) return testFile2;
164
+ return findTsTestFile(sourceFile, dir, baseName);
115
165
  }
116
166
 
117
167
  if (ext === '.py') {
118
- // Python: foo.py → foo_test.py or tests/test_foo.py
119
- const testFile1 = sourceFile.replace(/\.py$/, '_test.py');
120
- if (await fileExists(testFile1)) return testFile1;
168
+ const pyTest = sourceFile.replace(/\.py$/, '_test.py');
169
+ if (await fileExists(pyTest)) return pyTest;
170
+ return findPyTestFile(dir, baseName);
171
+ }
121
172
 
122
- const testFile2 = path.join('tests', `test_${baseName}.py`);
123
- if (await fileExists(testFile2)) return testFile2;
173
+ if (ext === '.go') {
174
+ const goTest = sourceFile.replace(/\.go$/, '_test.go');
175
+ if (await fileExists(goTest)) return goTest;
176
+ return null;
177
+ }
124
178
 
125
- const testFile3 = path.join(dir, '__tests__', `test_${baseName}.py`);
126
- if (await fileExists(testFile3)) return testFile3;
179
+ if (ext === '.java') {
180
+ return findJavaTestFile(dir, baseName);
127
181
  }
128
182
 
129
- if (ext === '.go') {
130
- // Go: foo.go → foo_test.go (same directory, idiomatic convention)
131
- const testFile1 = sourceFile.replace(/\.go$/, '_test.go');
132
- if (await fileExists(testFile1)) return testFile1;
183
+ if (ext === '.kt' || ext === '.kts') {
184
+ return findKotlinTestFile(dir, baseName);
133
185
  }
134
186
 
135
187
  return null;
@@ -375,123 +427,115 @@ async function runAllRunners(
375
427
  if (!runner) continue;
376
428
 
377
429
  if (!await runner.isAvailable()) {
378
- for (const f of files) fileScores[f] = null;
430
+ applyNullScores(files, fileScores);
379
431
  continue;
380
432
  }
381
433
 
382
- const options: RunMutationOptions = {
383
- files,
384
- timeoutMs,
385
- cwd,
386
- };
387
-
388
- const outcome: MutationRunOutcome = await runner.run(options);
434
+ const result = await runner.run({ files, timeoutMs, cwd });
435
+ applyMutationResult(result, files, fileScores);
436
+ }
389
437
 
390
- if (outcome.timedOut) {
391
- for (const f of files) fileScores[f] = null;
392
- continue;
393
- }
438
+ return fileScores;
439
+ }
394
440
 
395
- if (outcome.error) {
396
- for (const f of files) fileScores[f] = null;
397
- continue;
398
- }
441
+ function applyNullScores(files: string[], scores: Record<string, number | null>): void {
442
+ for (const f of files) scores[f] = null;
443
+ }
399
444
 
400
- if (!outcome.report) {
401
- for (const f of files) fileScores[f] = null;
402
- continue;
403
- }
445
+ function applyMutationResult(
446
+ outcome: MutationRunOutcome,
447
+ files: string[],
448
+ scores: Record<string, number | null>,
449
+ ): void {
450
+ if (outcome.timedOut || outcome.error || !outcome.report) {
451
+ for (const f of files) scores[f] = null;
452
+ return;
453
+ }
404
454
 
405
- // If per-file scores exist, use them
406
- if (outcome.report.files) {
407
- for (const [fileKey, fileReport] of Object.entries(outcome.report.files)) {
408
- fileScores[fileKey] = fileReport.mutationScore;
409
- }
410
- } else {
411
- // Top-level score applies to all files
412
- for (const f of files) {
413
- fileScores[f] = outcome.report.mutationScore;
414
- }
455
+ if (outcome.report.files) {
456
+ for (const [fileKey, fileReport] of Object.entries(outcome.report.files)) {
457
+ scores[fileKey] = fileReport.mutationScore;
415
458
  }
459
+ return;
416
460
  }
417
461
 
418
- return fileScores;
462
+ for (const f of files) scores[f] = outcome.report.mutationScore;
419
463
  }
420
464
 
421
465
  // ── Main gateway ──
422
466
 
467
+ function logThresholdDetails(thresholds: FileThreshold[], baseline: MutationBaseline | null): void {
468
+ if (baseline) {
469
+ console.log(` Loaded baseline (${Object.keys(baseline.scores).length} files)`);
470
+ }
471
+ for (const ft of thresholds) {
472
+ const level = ft.isCriticalPath ? 'critical path' : 'default';
473
+ const thresholdSource = ft.explicitThreshold !== undefined
474
+ ? 'explicit annotation'
475
+ : ft.isCriticalPath
476
+ ? 'critical path config'
477
+ : 'default';
478
+ console.log(` ${ft.file}: threshold=${ft.threshold}% (${level}, ${thresholdSource})`);
479
+ }
480
+ }
481
+
482
+ function buildMutationScores(fileScores: Record<string, number>): Record<string, MutationScore> {
483
+ const scores: Record<string, MutationScore> = {};
484
+ for (const [file, score] of Object.entries(fileScores)) {
485
+ scores[file] = { score, mutants: 0, killed: 0, survived: 0 };
486
+ }
487
+ return scores;
488
+ }
489
+
423
490
  export async function runGateM(options: GateMOptions): Promise<GateMResult> {
424
491
  const warnings: string[] = [];
425
492
  const errors: string[] = [];
426
493
 
427
494
  const sourceFiles = filterSourceFiles(options.changedFiles);
428
-
429
495
  if (sourceFiles.length === 0) {
430
496
  return buildResult('skip', 0, {}, warnings, errors);
431
497
  }
432
498
 
433
- // Test intent check (async, fire-and-forget warning)
434
499
  await collectTestIntentWarnings(sourceFiles, warnings);
435
500
 
436
501
  const thresholds = await determineThresholds(sourceFiles, await loadCriticalPaths(options.criticalPathsPath));
437
502
  const baseline = await loadBaseline(options.baselinePath);
438
503
 
439
- if (baseline) {
440
- console.log(` Loaded baseline from ${options.baselinePath} (${Object.keys(baseline.scores).length} files)`);
441
- }
442
-
443
- for (const ft of thresholds) {
444
- const level = ft.isCriticalPath ? 'critical path' : 'default';
445
- const thresholdSource = ft.explicitThreshold !== undefined
446
- ? 'explicit annotation'
447
- : ft.isCriticalPath
448
- ? 'critical path config'
449
- : 'default';
450
- console.log(` ${ft.file}: threshold=${ft.threshold}% (${level}, ${thresholdSource})`);
451
- }
504
+ logThresholdDetails(thresholds, baseline);
452
505
 
453
- // Route to runners by file extension
454
506
  const groups = groupByRunner(sourceFiles);
455
- const cwd = process.cwd();
456
- const fileScoresNullable = await runAllRunners(groups, options.timeoutMs, cwd);
457
-
458
- // Build final scores map
459
- const fileScores: Record<string, number> = {};
460
- const timeoutFiles: string[] = [];
461
-
462
- for (const [file, maybeScore] of Object.entries(fileScoresNullable)) {
463
- if (maybeScore === null) {
464
- timeoutFiles.push(file);
465
- } else {
466
- fileScores[file] = maybeScore;
467
- }
468
- }
507
+ const fileScoresNullable = await runAllRunners(groups, options.timeoutMs, process.cwd());
469
508
 
509
+ const { fileScores, timeoutFiles } = partitionScores(fileScoresNullable);
470
510
  if (timeoutFiles.length > 0) {
471
511
  warnings.push(`Mutation testing timed out for: ${timeoutFiles.join(', ')}. Run locally for full report.`);
472
512
  }
473
513
 
474
514
  const evalResult = evaluateScores(fileScores, thresholds, baseline);
515
+ for (const msg of evalResult.messages) console.log(` ${msg}`);
475
516
 
476
- for (const msg of evalResult.messages) {
477
- console.log(` ${msg}`);
517
+ if (evalResult.blocked) {
518
+ return buildResult('block', sourceFiles.length, buildMutationScores(fileScores), warnings, errors);
478
519
  }
520
+ return buildResult('pass', sourceFiles.length, buildMutationScores(fileScores), warnings, errors);
521
+ }
479
522
 
480
- const mutationScores: Record<string, MutationScore> = {};
481
- for (const [file, score] of Object.entries(fileScores)) {
482
- mutationScores[file] = {
483
- score,
484
- mutants: 0, // Detailed stats only available from per-file reports
485
- killed: 0,
486
- survived: 0,
487
- };
488
- }
523
+ interface PartitionedScores {
524
+ fileScores: Record<string, number>;
525
+ timeoutFiles: string[];
526
+ }
489
527
 
490
- if (evalResult.blocked) {
491
- return buildResult('block', sourceFiles.length, mutationScores, warnings, errors);
528
+ function partitionScores(input: Record<string, number | null>): PartitionedScores {
529
+ const fileScores: Record<string, number> = {};
530
+ const timeoutFiles: string[] = [];
531
+ for (const [file, maybeScore] of Object.entries(input)) {
532
+ if (maybeScore === null) {
533
+ timeoutFiles.push(file);
534
+ } else {
535
+ fileScores[file] = maybeScore;
536
+ }
492
537
  }
493
-
494
- return buildResult('pass', sourceFiles.length, mutationScores, warnings, errors);
538
+ return { fileScores, timeoutFiles };
495
539
  }
496
540
 
497
541
  async function collectTestIntentWarnings(sourceFiles: string[], warnings: string[]): Promise<void> {
@@ -12,10 +12,12 @@ export { registerRunner, resolveRunner, runnerRegistry } from './types';
12
12
  export { StrykerRunner } from './stryker-runner';
13
13
  export { MutmutRunner } from './mutmut-runner';
14
14
  export { GoMutantRunner } from './go-mutant-runner';
15
+ export { PitestRunner } from './pitest-runner';
15
16
 
16
17
  import { StrykerRunner } from './stryker-runner';
17
18
  import { MutmutRunner } from './mutmut-runner';
18
19
  import { GoMutantRunner } from './go-mutant-runner';
20
+ import { PitestRunner } from './pitest-runner';
19
21
  import { registerRunner } from './types';
20
22
 
21
23
  /** Auto-register all known runners. */
@@ -23,4 +25,5 @@ export function registerAllRunners(): void {
23
25
  registerRunner(new StrykerRunner());
24
26
  registerRunner(new MutmutRunner());
25
27
  registerRunner(new GoMutantRunner());
28
+ registerRunner(new PitestRunner());
26
29
  }
@@ -0,0 +1,290 @@
1
+ // @no-test-required: PITest wrapper — tested via gate-m integration + pre-push Gate M
2
+ import { execSync, spawn } from 'child_process';
3
+ import { existsSync, readFileSync, readdirSync } from 'fs';
4
+ import { join } from 'path';
5
+ import type {
6
+ MutationRunner,
7
+ RunMutationOptions,
8
+ MutationRunOutcome,
9
+ MutationRunResult,
10
+ } from './types';
11
+
12
+ /** JSON shape emitted by PITest when run with -DoutputFormats=JSON */
13
+ interface PitestJsonReport {
14
+ mutations?: {
15
+ /** One of: KILLED, SURVIVED, NO_COVERAGE, TIMED_OUT, NON_VIABLE, MEMORY_ERROR, RUN_ERROR */
16
+ status?: string;
17
+ mutatedClass?: string;
18
+ sourceFile?: string;
19
+ }[];
20
+ /** PITest also writes "totals" in some versions — prefer counting from mutations array */
21
+ }
22
+
23
+ /**
24
+ * PITest mutation runner for Java and Kotlin files.
25
+ *
26
+ * Supports Maven (pom.xml with pitest-maven plugin) and Gradle
27
+ * (build.gradle/build.gradle.kts with info.solidsoft.pitest plugin).
28
+ *
29
+ * PITest CLI:
30
+ * Maven: mvn org.pitest:pitest-maven:mutationCoverage -DoutputFormats=JSON
31
+ * Gradle: ./gradlew pitest
32
+ *
33
+ * Parses the target/pit-reports/.../mutations.json (Maven) or
34
+ * build/reports/pitest/mutations.json (Gradle) into MutationRunResult.
35
+ */
36
+ export class PitestRunner implements MutationRunner {
37
+ readonly name = 'pitest';
38
+ readonly extensions = ['java', 'kt', 'kts'];
39
+
40
+ async isAvailable(): Promise<boolean> {
41
+ // Check Maven availability: mvn exists + pom.xml with pitest-maven plugin
42
+ try {
43
+ execSync('mvn --version', { stdio: 'pipe', timeout: 5000 });
44
+ if (existsSync('pom.xml')) {
45
+ const pom = readFileSync('pom.xml', 'utf-8');
46
+ if (
47
+ pom.includes('pitest-maven') ||
48
+ pom.includes('org.pitest')
49
+ ) {
50
+ return true;
51
+ }
52
+ }
53
+ } catch {
54
+ // Maven not available
55
+ }
56
+
57
+ // Check Gradle availability: gradle/gradlew exists + build.gradle(.kts) with pitest plugin
58
+ const gradlewPath = './gradlew';
59
+ const hasGradlew = existsSync(gradlewPath);
60
+ try {
61
+ if (!hasGradlew) {
62
+ execSync('gradle --version', { stdio: 'pipe', timeout: 5000 });
63
+ }
64
+ const buildGradle = existsSync('build.gradle.kts')
65
+ ? 'build.gradle.kts'
66
+ : existsSync('build.gradle')
67
+ ? 'build.gradle'
68
+ : null;
69
+ if (buildGradle) {
70
+ const content = readFileSync(buildGradle, 'utf-8');
71
+ if (
72
+ content.includes('info.solidsoft.pitest') ||
73
+ content.includes('info.solidsoft.gradle.pitest')
74
+ ) {
75
+ return true;
76
+ }
77
+ }
78
+ } catch {
79
+ return false;
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ async run(options: RunMutationOptions): Promise<MutationRunOutcome> {
86
+ const buildTool = this.detectBuildTool(options.cwd);
87
+
88
+ if (buildTool === 'maven') {
89
+ return this.runMaven(options);
90
+ } else if (buildTool === 'gradle') {
91
+ return this.runGradle(options);
92
+ }
93
+
94
+ return {
95
+ report: null,
96
+ timedOut: false,
97
+ error: 'No supported build tool found (need Maven pom.xml with pitest-maven or Gradle with info.solidsoft.pitest)',
98
+ };
99
+ }
100
+
101
+ private detectBuildTool(cwd: string): 'maven' | 'gradle' | null {
102
+ if (existsSync(join(cwd, 'pom.xml'))) {
103
+ try {
104
+ execSync('mvn --version', { stdio: 'pipe', timeout: 5000 });
105
+ return 'maven';
106
+ } catch {
107
+ // mvn not available
108
+ }
109
+ }
110
+
111
+ if (
112
+ existsSync(join(cwd, 'build.gradle')) ||
113
+ existsSync(join(cwd, 'build.gradle.kts'))
114
+ ) {
115
+ if (existsSync(join(cwd, 'gradlew'))) {
116
+ return 'gradle';
117
+ }
118
+ try {
119
+ execSync('gradle --version', { stdio: 'pipe', timeout: 5000 });
120
+ return 'gradle';
121
+ } catch {
122
+ // gradle not available
123
+ }
124
+ }
125
+
126
+ return null;
127
+ }
128
+
129
+ private runMaven(options: RunMutationOptions): Promise<MutationRunOutcome> {
130
+ return new Promise((resolve) => {
131
+ const child = spawn(
132
+ 'mvn',
133
+ [
134
+ 'test-compile',
135
+ 'org.pitest:pitest-maven:mutationCoverage',
136
+ '-DoutputFormats=JSON',
137
+ ],
138
+ {
139
+ stdio: 'pipe',
140
+ shell: false,
141
+ cwd: options.cwd,
142
+ },
143
+ );
144
+
145
+ let stderr = '';
146
+ let stdout = '';
147
+
148
+ child.stdout?.on('data', (data: Buffer) => {
149
+ stdout += data.toString();
150
+ });
151
+
152
+ child.stderr?.on('data', (data: Buffer) => {
153
+ stderr += data.toString();
154
+ });
155
+
156
+ const timeoutId = setTimeout(() => {
157
+ child.kill('SIGTERM');
158
+ setTimeout(() => {
159
+ if (!child.killed) child.kill('SIGKILL');
160
+ }, 5000);
161
+ }, options.timeoutMs);
162
+
163
+ child.on('close', (code) => {
164
+ clearTimeout(timeoutId);
165
+
166
+ if (code === null) {
167
+ resolve({ report: null, timedOut: true });
168
+ return;
169
+ }
170
+
171
+ const report = this.parsePitestJson(options.cwd, 'maven');
172
+ resolve({
173
+ report,
174
+ timedOut: false,
175
+ error: code !== 0 ? stderr || stdout : undefined,
176
+ });
177
+ });
178
+
179
+ child.on('error', (err) => {
180
+ clearTimeout(timeoutId);
181
+ resolve({ report: null, timedOut: false, error: err.message });
182
+ });
183
+ });
184
+ }
185
+
186
+ private runGradle(options: RunMutationOptions): Promise<MutationRunOutcome> {
187
+ return new Promise((resolve) => {
188
+ const gradlewPath = join(options.cwd, 'gradlew');
189
+ const gradleCmd = existsSync(gradlewPath) ? gradlewPath : 'gradle';
190
+
191
+ const child = spawn(gradleCmd, ['pitest'], {
192
+ stdio: 'pipe',
193
+ shell: false,
194
+ cwd: options.cwd,
195
+ });
196
+
197
+ let stderr = '';
198
+ let stdout = '';
199
+
200
+ child.stdout?.on('data', (data: Buffer) => {
201
+ stdout += data.toString();
202
+ });
203
+
204
+ child.stderr?.on('data', (data: Buffer) => {
205
+ stderr += data.toString();
206
+ });
207
+
208
+ const timeoutId = setTimeout(() => {
209
+ child.kill('SIGTERM');
210
+ setTimeout(() => {
211
+ if (!child.killed) child.kill('SIGKILL');
212
+ }, 5000);
213
+ }, options.timeoutMs);
214
+
215
+ child.on('close', (code) => {
216
+ clearTimeout(timeoutId);
217
+
218
+ if (code === null) {
219
+ resolve({ report: null, timedOut: true });
220
+ return;
221
+ }
222
+
223
+ const report = this.parsePitestJson(options.cwd, 'gradle');
224
+ resolve({
225
+ report,
226
+ timedOut: false,
227
+ error: code !== 0 ? stderr || stdout : undefined,
228
+ });
229
+ });
230
+
231
+ child.on('error', (err) => {
232
+ clearTimeout(timeoutId);
233
+ resolve({ report: null, timedOut: false, error: err.message });
234
+ });
235
+ });
236
+ }
237
+
238
+ private findMavenReportPath(cwd: string): string | null {
239
+ const reportsDir = join(cwd, 'target', 'pit-reports');
240
+ if (!existsSync(reportsDir)) return null;
241
+ for (const entry of readdirSync(reportsDir)) {
242
+ const candidate = join(reportsDir, entry, 'mutations.json');
243
+ if (existsSync(candidate)) return candidate;
244
+ }
245
+ return null;
246
+ }
247
+
248
+ private findGradleReportPath(cwd: string): string | null {
249
+ const candidate = join(cwd, 'build', 'reports', 'pitest', 'mutations.json');
250
+ return existsSync(candidate) ? candidate : null;
251
+ }
252
+
253
+ private computeScore(data: PitestJsonReport): MutationRunResult | null {
254
+ if (!data.mutations || data.mutations.length === 0) return null;
255
+ const nrOfKilledMutants = data.mutations.filter(
256
+ (m) => m.status === 'KILLED',
257
+ ).length;
258
+ const nrOfMutants = data.mutations.length;
259
+ const nrOfSurvivedMutants = nrOfMutants - nrOfKilledMutants;
260
+ const mutationScore = nrOfMutants === 0
261
+ ? 0
262
+ : (nrOfKilledMutants / nrOfMutants) * 100;
263
+ return { mutationScore, nrOfMutants, nrOfKilledMutants, nrOfSurvivedMutants };
264
+ }
265
+
266
+ /**
267
+ * Locate and parse the PITest JSON report file.
268
+ *
269
+ * Maven writes mutations.json under a timestamped subdirectory:
270
+ * target/pit-reports/<YYYYMMDDHHMM>/mutations.json
271
+ * This method scans all subdirectories to find the latest report.
272
+ *
273
+ * Gradle writes to: build/reports/pitest/mutations.json
274
+ */
275
+ private parsePitestJson(
276
+ cwd: string,
277
+ buildTool: 'maven' | 'gradle',
278
+ ): MutationRunResult | null {
279
+ try {
280
+ const jsonPath = buildTool === 'maven'
281
+ ? this.findMavenReportPath(cwd)
282
+ : this.findGradleReportPath(cwd);
283
+ if (!jsonPath) return null;
284
+ const data: PitestJsonReport = JSON.parse(readFileSync(jsonPath, 'utf-8'));
285
+ return this.computeScore(data);
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
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-30
4
- **Commit:** f1117d4
5
- **Branch:** main
6
- **Version:** 0.11.3.0
3
+ **Generated:** 2026-07-01
4
+ **Commit:** a5ef11d
5
+ **Branch:** sprint/2026-06-30-01
6
+ **Version:** 0.11.4.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,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-06-30
4
- **Commit:** f1117d4
5
- **Branch:** main
6
- **Version:** 0.11.3.0
3
+ **Generated:** 2026-07-01
4
+ **Commit:** a5ef11d
5
+ **Branch:** sprint/2026-06-30-01
6
+ **Version:** 0.11.4.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **11-phase** development pipeline: ISOLATE → AUTO-ESTIMATE → THINK → PLAN → BUILD → REVIEW → SHIP → LAND → USER ACCEPTANCE → FEEDBACK → 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.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-06-30
4
- **Commit:** f1117d4
5
- **Branch:** main
6
- **Version:** 0.11.3.0
3
+ **Generated:** 2026-07-01
4
+ **Commit:** a5ef11d
5
+ **Branch:** sprint/2026-06-30-01
6
+ **Version:** 0.11.4.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.