@boyingliu01/xp-gate 0.9.5 → 0.10.1
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/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/gate-m.ts +209 -274
- package/mutation/runners/index.ts +23 -0
- package/mutation/runners/mutmut-runner.ts +221 -0
- package/mutation/runners/stryker-runner.ts +147 -0
- package/mutation/runners/types.ts +100 -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/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/tui-plugin.ts +62 -3
- 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/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/mutation/gate-m.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { spawn } from 'child_process';
|
|
4
3
|
import {
|
|
5
4
|
GateMOptions,
|
|
6
5
|
GateMResult,
|
|
@@ -8,16 +7,22 @@ import {
|
|
|
8
7
|
MutationBaseline,
|
|
9
8
|
MutationScore,
|
|
10
9
|
FileThreshold,
|
|
11
|
-
ScoreEvaluation
|
|
12
|
-
TestIntentCheckResult
|
|
10
|
+
ScoreEvaluation
|
|
13
11
|
} from './types';
|
|
14
|
-
import { StrykerReport } from './stryker-types';
|
|
15
12
|
import { detectAITestCharacteristics } from './detect-ai-test';
|
|
13
|
+
import {
|
|
14
|
+
resolveRunner,
|
|
15
|
+
registerAllRunners,
|
|
16
|
+
RunMutationOptions,
|
|
17
|
+
MutationRunOutcome,
|
|
18
|
+
} from './runners';
|
|
19
|
+
|
|
20
|
+
// ── Pre-run auto-registration of all language runners ──
|
|
21
|
+
|
|
22
|
+
registerAllRunners();
|
|
16
23
|
|
|
17
24
|
const DEFAULT_THRESHOLD = 60;
|
|
18
25
|
const CRITICAL_PATH_THRESHOLD = 80;
|
|
19
|
-
const STRYKER_REPORT_PATH = '.stryker-report.json';
|
|
20
|
-
const STRYKER_CONFIG = 'stryker.prepush.conf.json';
|
|
21
26
|
|
|
22
27
|
type ArgHandler = (options: GateMOptions, args: string[], i: number) => void;
|
|
23
28
|
|
|
@@ -25,7 +30,7 @@ const ARG_HANDLERS: Record<string, ArgHandler> = {
|
|
|
25
30
|
'--changed-files': parseChangedFiles,
|
|
26
31
|
'--baseline': parseBaseline,
|
|
27
32
|
'--critical-paths': parseCriticalPaths,
|
|
28
|
-
'--timeout': parseTimeout
|
|
33
|
+
'--timeout': parseTimeout,
|
|
29
34
|
};
|
|
30
35
|
|
|
31
36
|
function parseArgs(args: string[]): GateMOptions {
|
|
@@ -33,7 +38,7 @@ function parseArgs(args: string[]): GateMOptions {
|
|
|
33
38
|
changedFiles: [],
|
|
34
39
|
baselinePath: '.mutation-baseline.json',
|
|
35
40
|
criticalPathsPath: '.mutation-critical-paths',
|
|
36
|
-
timeoutMs: 120000
|
|
41
|
+
timeoutMs: 120000,
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -68,13 +73,21 @@ function parseTimeout(options: GateMOptions, args: string[], i: number): void {
|
|
|
68
73
|
if (next) options.timeoutMs = parseInt(next, 10);
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
// ── File filtering: excludes tests, declarations, adapters ──
|
|
77
|
+
// Delegate per-language filtering to a helper.
|
|
78
|
+
|
|
71
79
|
function filterSourceFiles(files: string[]): string[] {
|
|
72
80
|
return files.filter(file => {
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
const ext = path.extname(file);
|
|
82
|
+
// Skip test files
|
|
83
|
+
if (file.includes('.test.') || file.includes('_test.')) return false;
|
|
84
|
+
// Skip declaration files
|
|
75
85
|
if (file.endsWith('.d.ts')) return false;
|
|
86
|
+
// Skip adapter files
|
|
76
87
|
if (file.includes('/adapters/')) return false;
|
|
77
|
-
|
|
88
|
+
// Skip files with no registered runner
|
|
89
|
+
const runner = resolveRunner(ext);
|
|
90
|
+
return runner !== undefined;
|
|
78
91
|
});
|
|
79
92
|
}
|
|
80
93
|
|
|
@@ -87,33 +100,51 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|
|
87
100
|
}
|
|
88
101
|
}
|
|
89
102
|
|
|
90
|
-
async function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
async function findTestFileForSource(sourceFile: string): Promise<string | null> {
|
|
104
|
+
const ext = path.extname(sourceFile);
|
|
105
|
+
const baseName = path.basename(sourceFile, ext);
|
|
94
106
|
const dir = path.dirname(sourceFile);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
|
|
108
|
+
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;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
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;
|
|
121
|
+
|
|
122
|
+
const testFile2 = path.join('tests', `test_${baseName}.py`);
|
|
123
|
+
if (await fileExists(testFile2)) return testFile2;
|
|
124
|
+
|
|
125
|
+
const testFile3 = path.join(dir, '__tests__', `test_${baseName}.py`);
|
|
126
|
+
if (await fileExists(testFile3)) return testFile3;
|
|
127
|
+
}
|
|
98
128
|
|
|
99
129
|
return null;
|
|
100
130
|
}
|
|
101
131
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
132
|
+
interface TestIntentCheckResult {
|
|
133
|
+
sourceFile: string;
|
|
134
|
+
testFile: string | null;
|
|
135
|
+
missingAnnotations: string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function checkTestIntents(sourceFiles: string[]): Promise<TestIntentCheckResult[]> {
|
|
105
139
|
const results: TestIntentCheckResult[] = [];
|
|
106
140
|
|
|
107
141
|
for (const sourceFile of sourceFiles) {
|
|
108
|
-
const testFile = await
|
|
142
|
+
const testFile = await findTestFileForSource(sourceFile);
|
|
109
143
|
if (!testFile) {
|
|
110
144
|
results.push({
|
|
111
145
|
sourceFile,
|
|
112
146
|
testFile: null,
|
|
113
|
-
|
|
114
|
-
hasIntentAnnotation: false,
|
|
115
|
-
hasCoversAnnotation: false,
|
|
116
|
-
missingAnnotations: ['@test', '@intent', '@covers']
|
|
147
|
+
missingAnnotations: ['@test', '@intent', '@covers'],
|
|
117
148
|
});
|
|
118
149
|
continue;
|
|
119
150
|
}
|
|
@@ -127,10 +158,7 @@ async function checkTestIntents(
|
|
|
127
158
|
results.push({
|
|
128
159
|
sourceFile,
|
|
129
160
|
testFile,
|
|
130
|
-
|
|
131
|
-
hasIntentAnnotation: detection.annotations.hasIntent,
|
|
132
|
-
hasCoversAnnotation: detection.annotations.hasCovers,
|
|
133
|
-
missingAnnotations
|
|
161
|
+
missingAnnotations,
|
|
134
162
|
});
|
|
135
163
|
}
|
|
136
164
|
|
|
@@ -204,7 +232,7 @@ async function determineThresholds(
|
|
|
204
232
|
const thresholds: FileThreshold[] = [];
|
|
205
233
|
|
|
206
234
|
for (const file of sourceFiles) {
|
|
207
|
-
const testFile = await
|
|
235
|
+
const testFile = await findTestFileForSource(file);
|
|
208
236
|
let explicitThreshold: number | undefined;
|
|
209
237
|
if (testFile) {
|
|
210
238
|
const detection = await detectAITestCharacteristics(testFile);
|
|
@@ -222,116 +250,33 @@ async function determineThresholds(
|
|
|
222
250
|
file,
|
|
223
251
|
threshold,
|
|
224
252
|
isCriticalPath: isCritical,
|
|
225
|
-
explicitThreshold
|
|
253
|
+
explicitThreshold,
|
|
226
254
|
});
|
|
227
255
|
}
|
|
228
256
|
|
|
229
257
|
return thresholds;
|
|
230
258
|
}
|
|
231
259
|
|
|
232
|
-
|
|
233
|
-
files: string[],
|
|
234
|
-
timeoutMs: number
|
|
235
|
-
): Promise<{ report: StrykerReport | null; timedOut: boolean; error?: string }> {
|
|
236
|
-
return new Promise((resolve) => {
|
|
237
|
-
const args = [
|
|
238
|
-
'stryker',
|
|
239
|
-
'run',
|
|
240
|
-
'--config',
|
|
241
|
-
STRYKER_CONFIG,
|
|
242
|
-
...files.flatMap(f => ['--mutate', f])
|
|
243
|
-
];
|
|
244
|
-
|
|
245
|
-
const child = spawn('npx', args, {
|
|
246
|
-
stdio: 'pipe',
|
|
247
|
-
shell: false
|
|
248
|
-
});
|
|
260
|
+
// ── Per-file extension grouping → route to correct runner ──
|
|
249
261
|
|
|
250
|
-
|
|
251
|
-
|
|
262
|
+
function groupByRunner(files: string[]): Map<string, string[]> {
|
|
263
|
+
const groups = new Map<string, string[]>();
|
|
252
264
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
const ext = path.extname(file);
|
|
267
|
+
const runner = resolveRunner(ext);
|
|
268
|
+
if (!runner) continue;
|
|
256
269
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const timeoutId = setTimeout(() => {
|
|
262
|
-
child.kill('SIGTERM');
|
|
263
|
-
setTimeout(() => {
|
|
264
|
-
if (!child.killed) {
|
|
265
|
-
child.kill('SIGKILL');
|
|
266
|
-
}
|
|
267
|
-
}, 5000);
|
|
268
|
-
}, timeoutMs);
|
|
269
|
-
|
|
270
|
-
child.on('close', async (code) => {
|
|
271
|
-
clearTimeout(timeoutId);
|
|
272
|
-
|
|
273
|
-
if (code === null) {
|
|
274
|
-
resolve({ report: null, timedOut: true });
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const report = await parseStrykerReport(STRYKER_REPORT_PATH);
|
|
279
|
-
resolve({ report, timedOut: false, error: code !== 0 ? stderr || stdout : undefined });
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
child.on('error', (err) => {
|
|
283
|
-
clearTimeout(timeoutId);
|
|
284
|
-
resolve({ report: null, timedOut: false, error: err.message });
|
|
285
|
-
});
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
async function parseStrykerReport(reportPath: string): Promise<StrykerReport | null> {
|
|
290
|
-
try {
|
|
291
|
-
const content = await fs.readFile(reportPath, 'utf-8');
|
|
292
|
-
const parsed = JSON.parse(content);
|
|
293
|
-
return parseReportObject(parsed);
|
|
294
|
-
} catch {
|
|
295
|
-
return null;
|
|
270
|
+
const key = runner.name;
|
|
271
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
272
|
+
groups.get(key)!.push(file);
|
|
296
273
|
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function parseReportObject(parsed: Record<string, unknown>): StrykerReport {
|
|
300
|
-
const report: StrykerReport = {
|
|
301
|
-
mutationScore: asNumber(parsed.mutationScore),
|
|
302
|
-
nrOfMutants: asNumber(parsed.nrOfMutants),
|
|
303
|
-
nrOfKilledMutants: asNumber(parsed.nrOfKilledMutants),
|
|
304
|
-
nrOfSurvivedMutants: asNumber(parsed.nrOfSurvivedMutants),
|
|
305
|
-
};
|
|
306
274
|
|
|
307
|
-
|
|
308
|
-
report.files = parseFilesObject(parsed.files as Record<string, Record<string, unknown>>);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return report;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function asNumber(value: unknown, fallback = 0): number {
|
|
315
|
-
return typeof value === 'number' ? value : fallback;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function parseFilesObject(
|
|
319
|
-
filesObj: Record<string, Record<string, unknown>>
|
|
320
|
-
): Record<string, { mutationScore: number; nrOfMutants: number; nrOfKilledMutants: number; nrOfSurvivedMutants: number }> {
|
|
321
|
-
const files: Record<string, { mutationScore: number; nrOfMutants: number; nrOfKilledMutants: number; nrOfSurvivedMutants: number }> = {};
|
|
322
|
-
for (const [file, data] of Object.entries(filesObj)) {
|
|
323
|
-
files[file] = {
|
|
324
|
-
mutationScore: asNumber(data.mutationScore),
|
|
325
|
-
nrOfMutants: asNumber(data.nrOfMutants),
|
|
326
|
-
nrOfKilledMutants: asNumber(data.nrOfKilledMutants),
|
|
327
|
-
nrOfSurvivedMutants: asNumber(data.nrOfSurvivedMutants),
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
return files;
|
|
275
|
+
return groups;
|
|
331
276
|
}
|
|
332
277
|
|
|
333
278
|
function evaluateScores(
|
|
334
|
-
|
|
279
|
+
fileScores: Record<string, number>,
|
|
335
280
|
thresholds: FileThreshold[],
|
|
336
281
|
baseline: MutationBaseline | null
|
|
337
282
|
): { evaluations: ScoreEvaluation[]; blocked: boolean; messages: string[] } {
|
|
@@ -340,47 +285,39 @@ function evaluateScores(
|
|
|
340
285
|
let blocked = false;
|
|
341
286
|
|
|
342
287
|
for (const ft of thresholds) {
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
if (!result.evaluation.passed) blocked = true;
|
|
346
|
-
messages.push(result.message);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return { evaluations, blocked, messages };
|
|
350
|
-
}
|
|
288
|
+
const score = fileScores[ft.file] ?? 0;
|
|
289
|
+
const baselineScore = baseline?.scores[ft.file]?.score;
|
|
351
290
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
): { evaluation: ScoreEvaluation; message: string } {
|
|
357
|
-
const fileReport = report.files
|
|
358
|
-
? Object.entries(report.files).find(([key]) => key === ft.file)?.[1]
|
|
359
|
-
: undefined;
|
|
291
|
+
let effectiveThreshold = ft.threshold;
|
|
292
|
+
if (baselineScore !== undefined && score < baselineScore) {
|
|
293
|
+
effectiveThreshold = Math.max(effectiveThreshold, baselineScore);
|
|
294
|
+
}
|
|
360
295
|
|
|
361
|
-
|
|
362
|
-
|
|
296
|
+
const passed = score >= effectiveThreshold;
|
|
297
|
+
const isRegression = baselineScore !== undefined && score < baselineScore;
|
|
363
298
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
299
|
+
const evaluation: ScoreEvaluation = {
|
|
300
|
+
file: ft.file,
|
|
301
|
+
score,
|
|
302
|
+
threshold: effectiveThreshold,
|
|
303
|
+
baselineScore,
|
|
304
|
+
passed,
|
|
305
|
+
isRegression,
|
|
306
|
+
};
|
|
307
|
+
evaluations.push(evaluation);
|
|
308
|
+
|
|
309
|
+
if (!passed) blocked = true;
|
|
310
|
+
messages.push(buildScoreMessage({
|
|
311
|
+
file: ft.file,
|
|
312
|
+
score,
|
|
313
|
+
effectiveThreshold,
|
|
314
|
+
baselineScore,
|
|
315
|
+
passed,
|
|
316
|
+
isRegression,
|
|
317
|
+
}));
|
|
367
318
|
}
|
|
368
319
|
|
|
369
|
-
|
|
370
|
-
const isRegression = baselineScore !== undefined && score < baselineScore;
|
|
371
|
-
|
|
372
|
-
const evaluation: ScoreEvaluation = {
|
|
373
|
-
file: ft.file,
|
|
374
|
-
score,
|
|
375
|
-
threshold: effectiveThreshold,
|
|
376
|
-
baselineScore,
|
|
377
|
-
passed,
|
|
378
|
-
isRegression
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
const message = buildScoreMessage({ file: ft.file, score, effectiveThreshold, baselineScore, passed, isRegression });
|
|
382
|
-
|
|
383
|
-
return { evaluation, message };
|
|
320
|
+
return { evaluations, blocked, messages };
|
|
384
321
|
}
|
|
385
322
|
|
|
386
323
|
interface ScoreMessageParams {
|
|
@@ -416,63 +353,86 @@ function buildResult(
|
|
|
416
353
|
filesChecked,
|
|
417
354
|
scores,
|
|
418
355
|
warnings,
|
|
419
|
-
errors
|
|
356
|
+
errors,
|
|
420
357
|
};
|
|
421
358
|
}
|
|
422
359
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
360
|
+
async function runAllRunners(
|
|
361
|
+
groups: Map<string, string[]>,
|
|
362
|
+
timeoutMs: number,
|
|
363
|
+
cwd: string
|
|
364
|
+
): Promise<Record<string, number | null>> {
|
|
365
|
+
const fileScores: Record<string, number | null> = {};
|
|
428
366
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
367
|
+
for (const [, files] of Array.from(groups.entries())) {
|
|
368
|
+
const runner = resolveRunner(path.extname(files[0]));
|
|
369
|
+
if (!runner) continue;
|
|
432
370
|
|
|
433
|
-
|
|
371
|
+
if (!await runner.isAvailable()) {
|
|
372
|
+
for (const f of files) fileScores[f] = null;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
434
375
|
|
|
435
|
-
|
|
436
|
-
|
|
376
|
+
const options: RunMutationOptions = {
|
|
377
|
+
files,
|
|
378
|
+
timeoutMs,
|
|
379
|
+
cwd,
|
|
380
|
+
};
|
|
437
381
|
|
|
438
|
-
|
|
439
|
-
const timeoutResult = handleTimeout(strykerResult, options, sourceFiles, warnings, errors);
|
|
440
|
-
if (timeoutResult) return timeoutResult;
|
|
382
|
+
const outcome: MutationRunOutcome = await runner.run(options);
|
|
441
383
|
|
|
442
|
-
|
|
443
|
-
|
|
384
|
+
if (outcome.timedOut) {
|
|
385
|
+
for (const f of files) fileScores[f] = null;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
444
388
|
|
|
445
|
-
|
|
446
|
-
|
|
389
|
+
if (outcome.error) {
|
|
390
|
+
for (const f of files) fileScores[f] = null;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
447
393
|
|
|
448
|
-
|
|
394
|
+
if (!outcome.report) {
|
|
395
|
+
for (const f of files) fileScores[f] = null;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
449
398
|
|
|
450
|
-
|
|
451
|
-
|
|
399
|
+
// If per-file scores exist, use them
|
|
400
|
+
if (outcome.report.files) {
|
|
401
|
+
for (const [fileKey, fileReport] of Object.entries(outcome.report.files)) {
|
|
402
|
+
fileScores[fileKey] = fileReport.mutationScore;
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
// Top-level score applies to all files
|
|
406
|
+
for (const f of files) {
|
|
407
|
+
fileScores[f] = outcome.report.mutationScore;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
452
410
|
}
|
|
453
411
|
|
|
454
|
-
return
|
|
412
|
+
return fileScores;
|
|
455
413
|
}
|
|
456
414
|
|
|
457
|
-
|
|
458
|
-
checkTestIntents(sourceFiles).then((results) => {
|
|
459
|
-
for (const result of results) {
|
|
460
|
-
if (result.missingAnnotations.length > 0) {
|
|
461
|
-
const testFileInfo = result.testFile ? ` (${result.testFile})` : ' (no test file found)';
|
|
462
|
-
warnings.push(
|
|
463
|
-
`Warning: ${result.sourceFile}${testFileInfo} missing annotations: ${result.missingAnnotations.join(', ')}`
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
});
|
|
468
|
-
}
|
|
415
|
+
// ── Main gateway ──
|
|
469
416
|
|
|
470
|
-
async function
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
417
|
+
export async function runGateM(options: GateMOptions): Promise<GateMResult> {
|
|
418
|
+
const warnings: string[] = [];
|
|
419
|
+
const errors: string[] = [];
|
|
420
|
+
|
|
421
|
+
const sourceFiles = filterSourceFiles(options.changedFiles);
|
|
422
|
+
|
|
423
|
+
if (sourceFiles.length === 0) {
|
|
424
|
+
return buildResult('skip', 0, {}, warnings, errors);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Test intent check (async, fire-and-forget warning)
|
|
428
|
+
await collectTestIntentWarnings(sourceFiles, warnings);
|
|
429
|
+
|
|
430
|
+
const thresholds = await determineThresholds(sourceFiles, await loadCriticalPaths(options.criticalPathsPath));
|
|
431
|
+
const baseline = await loadBaseline(options.baselinePath);
|
|
432
|
+
|
|
433
|
+
if (baseline) {
|
|
434
|
+
console.log(` Loaded baseline from ${options.baselinePath} (${Object.keys(baseline.scores).length} files)`);
|
|
435
|
+
}
|
|
476
436
|
|
|
477
437
|
for (const ft of thresholds) {
|
|
478
438
|
const level = ft.isCriticalPath ? 'critical path' : 'default';
|
|
@@ -484,84 +444,60 @@ async function determineThresholdsWithLogging(
|
|
|
484
444
|
console.log(` ${ft.file}: threshold=${ft.threshold}% (${level}, ${thresholdSource})`);
|
|
485
445
|
}
|
|
486
446
|
|
|
487
|
-
|
|
488
|
-
|
|
447
|
+
// Route to runners by file extension
|
|
448
|
+
const groups = groupByRunner(sourceFiles);
|
|
449
|
+
const cwd = process.cwd();
|
|
450
|
+
const fileScoresNullable = await runAllRunners(groups, options.timeoutMs, cwd);
|
|
489
451
|
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
452
|
+
// Build final scores map
|
|
453
|
+
const fileScores: Record<string, number> = {};
|
|
454
|
+
const timeoutFiles: string[] = [];
|
|
455
|
+
|
|
456
|
+
for (const [file, maybeScore] of Object.entries(fileScoresNullable)) {
|
|
457
|
+
if (maybeScore === null) {
|
|
458
|
+
timeoutFiles.push(file);
|
|
459
|
+
} else {
|
|
460
|
+
fileScores[file] = maybeScore;
|
|
461
|
+
}
|
|
494
462
|
}
|
|
495
|
-
return baseline;
|
|
496
|
-
}
|
|
497
463
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
sourceFiles: string[],
|
|
502
|
-
warnings: string[],
|
|
503
|
-
errors: string[]
|
|
504
|
-
): GateMResult | null {
|
|
505
|
-
if (!strykerResult.timedOut) return null;
|
|
464
|
+
if (timeoutFiles.length > 0) {
|
|
465
|
+
warnings.push(`Mutation testing timed out for: ${timeoutFiles.join(', ')}. Run locally for full report.`);
|
|
466
|
+
}
|
|
506
467
|
|
|
507
|
-
|
|
508
|
-
`Mutation testing timed out (>${options.timeoutMs}ms). Push allowed. Run 'npm run test:mutation' locally for full report.`
|
|
509
|
-
);
|
|
510
|
-
return buildResult('timeout', sourceFiles.length, {}, warnings, errors);
|
|
511
|
-
}
|
|
468
|
+
const evalResult = evaluateScores(fileScores, thresholds, baseline);
|
|
512
469
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
warnings: string[],
|
|
517
|
-
errors: string[]
|
|
518
|
-
): GateMResult | null {
|
|
519
|
-
if (!strykerResult.error || strykerResult.report) return null;
|
|
470
|
+
for (const msg of evalResult.messages) {
|
|
471
|
+
console.log(` ${msg}`);
|
|
472
|
+
}
|
|
520
473
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
474
|
+
const mutationScores: Record<string, MutationScore> = {};
|
|
475
|
+
for (const [file, score] of Object.entries(fileScores)) {
|
|
476
|
+
mutationScores[file] = {
|
|
477
|
+
score,
|
|
478
|
+
mutants: 0, // Detailed stats only available from per-file reports
|
|
479
|
+
killed: 0,
|
|
480
|
+
survived: 0,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
524
483
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
warnings: string[],
|
|
529
|
-
errors: string[]
|
|
530
|
-
): GateMResult | null {
|
|
531
|
-
if (strykerResult.report) return null;
|
|
484
|
+
if (evalResult.blocked) {
|
|
485
|
+
return buildResult('block', sourceFiles.length, mutationScores, warnings, errors);
|
|
486
|
+
}
|
|
532
487
|
|
|
533
|
-
|
|
534
|
-
return buildResult('block', sourceFiles.length, {}, warnings, errors);
|
|
488
|
+
return buildResult('pass', sourceFiles.length, mutationScores, warnings, errors);
|
|
535
489
|
}
|
|
536
490
|
|
|
537
|
-
function
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const scores: Record<string, MutationScore> = {};
|
|
546
|
-
|
|
547
|
-
if (report.files) {
|
|
548
|
-
for (const [file, data] of Object.entries(report.files)) {
|
|
549
|
-
scores[file] = {
|
|
550
|
-
score: data.mutationScore,
|
|
551
|
-
mutants: data.nrOfMutants,
|
|
552
|
-
killed: data.nrOfKilledMutants,
|
|
553
|
-
survived: data.nrOfSurvivedMutants
|
|
554
|
-
};
|
|
491
|
+
async function collectTestIntentWarnings(sourceFiles: string[], warnings: string[]): Promise<void> {
|
|
492
|
+
const results = await checkTestIntents(sourceFiles);
|
|
493
|
+
for (const result of results) {
|
|
494
|
+
if (result.missingAnnotations.length > 0) {
|
|
495
|
+
const testFileInfo = result.testFile ? ` (${result.testFile})` : ' (no test file found)';
|
|
496
|
+
warnings.push(
|
|
497
|
+
`Warning: ${result.sourceFile}${testFileInfo} missing annotations: ${result.missingAnnotations.join(', ')}`
|
|
498
|
+
);
|
|
555
499
|
}
|
|
556
500
|
}
|
|
557
|
-
|
|
558
|
-
const evalResult = evaluateScores(report, thresholds, baseline);
|
|
559
|
-
|
|
560
|
-
for (const msg of evalResult.messages) {
|
|
561
|
-
console.log(` ${msg}`);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
return { scores, blocked: evalResult.blocked, messages: evalResult.messages.filter(Boolean) };
|
|
565
501
|
}
|
|
566
502
|
|
|
567
503
|
export async function main(args: string[]): Promise<number> {
|
|
@@ -572,7 +508,7 @@ export async function main(args: string[]): Promise<number> {
|
|
|
572
508
|
return 1;
|
|
573
509
|
}
|
|
574
510
|
|
|
575
|
-
console.log(`Gate M: Mutation Testing`);
|
|
511
|
+
console.log(`Gate M: Mutation Testing (LangAdapter)`);
|
|
576
512
|
console.log(` Changed files: ${options.changedFiles.length}`);
|
|
577
513
|
console.log(` Baseline: ${options.baselinePath}`);
|
|
578
514
|
console.log(` Critical paths: ${options.criticalPathsPath}`);
|
|
@@ -603,9 +539,9 @@ export async function main(args: string[]): Promise<number> {
|
|
|
603
539
|
return result.exitCode;
|
|
604
540
|
}
|
|
605
541
|
|
|
606
|
-
const
|
|
542
|
+
const cliArgs = process.argv.slice(2);
|
|
607
543
|
if (typeof require !== 'undefined' && require.main === module) {
|
|
608
|
-
main(
|
|
544
|
+
main(cliArgs)
|
|
609
545
|
.then(exitCode => {
|
|
610
546
|
if (exitCode !== 0) {
|
|
611
547
|
process.exit(exitCode);
|
|
@@ -616,4 +552,3 @@ if (typeof require !== 'undefined' && require.main === module) {
|
|
|
616
552
|
process.exit(1);
|
|
617
553
|
});
|
|
618
554
|
}
|
|
619
|
-
// trigger Gate M
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @no-test-required: Re-exports and registry — tested via gate-m integration + pre-push Gate M
|
|
2
|
+
export type {
|
|
3
|
+
MutationFileReport,
|
|
4
|
+
MutationRunResult,
|
|
5
|
+
MutationRunOutcome,
|
|
6
|
+
RunMutationOptions,
|
|
7
|
+
MutationRunner,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
export { registerRunner, resolveRunner, runnerRegistry } from './types';
|
|
11
|
+
|
|
12
|
+
export { StrykerRunner } from './stryker-runner';
|
|
13
|
+
export { MutmutRunner } from './mutmut-runner';
|
|
14
|
+
|
|
15
|
+
import { StrykerRunner } from './stryker-runner';
|
|
16
|
+
import { MutmutRunner } from './mutmut-runner';
|
|
17
|
+
import { registerRunner } from './types';
|
|
18
|
+
|
|
19
|
+
/** Auto-register all known runners. */
|
|
20
|
+
export function registerAllRunners(): void {
|
|
21
|
+
registerRunner(new StrykerRunner());
|
|
22
|
+
registerRunner(new MutmutRunner());
|
|
23
|
+
}
|