@boyingliu01/xp-gate 0.8.8 → 0.8.10
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/bin/xp-gate.js +18 -0
- package/hooks/pre-commit +565 -120
- package/lib/arch.js +49 -0
- package/lib/check.js +50 -0
- package/lib/init.js +36 -0
- package/lib/principles.js +48 -0
- package/mock-policy/AGENTS.md +94 -0
- package/mock-policy/__tests__/config.test.ts +292 -0
- package/mock-policy/__tests__/integration.test.ts +600 -0
- package/mock-policy/__tests__/mock-decision-engine.test.ts +231 -0
- package/mock-policy/__tests__/scope-scanner.test.ts +535 -0
- package/mock-policy/config.ts +106 -0
- package/mock-policy/gate-m3.ts +233 -0
- package/mock-policy/mock-decision-engine.ts +155 -0
- package/mock-policy/schema.ts +22 -0
- package/mock-policy/scope-scanner.ts +288 -0
- package/mock-policy/types.ts +65 -0
- package/mutation/AGENTS.md +73 -0
- package/mutation/__tests__/detect-ai-test.test.ts +152 -0
- package/mutation/__tests__/gate-m.test.ts +194 -0
- package/mutation/detect-ai-test.ts +128 -0
- package/mutation/gate-m.ts +619 -0
- package/mutation/init-baseline.ts +192 -0
- package/mutation/stryker-types.ts +14 -0
- package/mutation/types.ts +70 -0
- package/mutation/update-baseline.ts +160 -0
- package/package.json +5 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/claude-code/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/claude-code/skills/delphi-review/SKILL.md +12 -12
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +83 -36
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/README.md +23 -7
- package/plugins/opencode/index.ts +36 -21
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/opencode/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/opencode/skills/delphi-review/SKILL.md +12 -12
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +83 -36
- package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +20 -0
- package/plugins/qoder/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/qoder/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/qoder/skills/delphi-review/SKILL.md +12 -12
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +83 -36
- package/plugins/qoder/skills/sprint-flow/SKILL.md +51 -1
- package/plugins/qoder/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/qoder/skills/test-driven-development/SKILL.md +371 -0
- package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +111 -0
- package/principles/__tests__/analyzer.test.ts +343 -0
- package/principles/__tests__/baseline-storage.test.ts +187 -0
- package/principles/__tests__/baseline.test.ts +328 -0
- package/principles/__tests__/boy-scout-integration.test.ts +264 -0
- package/principles/__tests__/boy-scout.test.ts +646 -0
- package/principles/__tests__/config.test.ts +78 -0
- package/principles/__tests__/index.test.ts +81 -0
- package/principles/__tests__/lint-baseline.test.ts +314 -0
- package/principles/__tests__/reporter.test.ts +832 -0
- package/principles/__tests__/types.test.ts +101 -0
- package/principles/adapters/__tests__/base.test.ts +95 -0
- package/principles/adapters/__tests__/cpp.test.ts +290 -0
- package/principles/adapters/__tests__/dart.test.ts +84 -0
- package/principles/adapters/__tests__/go.test.ts +68 -0
- package/principles/adapters/__tests__/java.test.ts +68 -0
- package/principles/adapters/__tests__/kotlin.test.ts +76 -0
- package/principles/adapters/__tests__/objectivec.test.ts +453 -0
- package/principles/adapters/__tests__/python.test.ts +84 -0
- package/principles/adapters/__tests__/swift.test.ts +68 -0
- package/principles/adapters/__tests__/typescript.test.ts +116 -0
- package/principles/adapters/base.ts +124 -0
- package/principles/adapters/cpp.ts +115 -0
- package/principles/adapters/dart.ts +44 -0
- package/principles/adapters/go.ts +55 -0
- package/principles/adapters/java.ts +56 -0
- package/principles/adapters/kotlin.ts +54 -0
- package/principles/adapters/objectivec.ts +82 -0
- package/principles/adapters/python.ts +83 -0
- package/principles/adapters/swift.ts +40 -0
- package/principles/adapters/typescript.ts +66 -0
- package/principles/analyzer.ts +190 -0
- package/principles/baseline.ts +273 -0
- package/principles/boy-scout.ts +416 -0
- package/principles/config.ts +118 -0
- package/principles/index.ts +87 -0
- package/principles/lint-baseline.ts +385 -0
- package/principles/reporter.ts +188 -0
- package/principles/rules/__tests__/clean-code/code-duplication.test.ts +84 -0
- package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +74 -0
- package/principles/rules/__tests__/clean-code/god-class.test.ts +111 -0
- package/principles/rules/__tests__/clean-code/large-file.test.ts +73 -0
- package/principles/rules/__tests__/clean-code/long-function.test.ts +83 -0
- package/principles/rules/__tests__/clean-code/magic-numbers.test.ts +68 -0
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +118 -0
- package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +173 -0
- package/principles/rules/__tests__/clean-code/too-many-params.test.ts +60 -0
- package/principles/rules/__tests__/clean-code/unused-imports.test.ts +107 -0
- package/principles/rules/__tests__/index.test.ts +49 -0
- package/principles/rules/__tests__/solid/dip.test.ts +121 -0
- package/principles/rules/__tests__/solid/isp.test.ts +60 -0
- package/principles/rules/__tests__/solid/lsp.test.ts +184 -0
- package/principles/rules/__tests__/solid/ocp.test.ts +69 -0
- package/principles/rules/__tests__/solid/srp.test.ts +89 -0
- package/principles/rules/clean-code/code-duplication.ts +31 -0
- package/principles/rules/clean-code/deep-nesting.ts +38 -0
- package/principles/rules/clean-code/god-class.ts +54 -0
- package/principles/rules/clean-code/large-file.ts +34 -0
- package/principles/rules/clean-code/long-function.ts +47 -0
- package/principles/rules/clean-code/magic-numbers.ts +59 -0
- package/principles/rules/clean-code/many-exports.ts +40 -0
- package/principles/rules/clean-code/missing-error-handling.ts +33 -0
- package/principles/rules/clean-code/too-many-params.ts +35 -0
- package/principles/rules/clean-code/unused-imports.ts +33 -0
- package/principles/rules/index.ts +61 -0
- package/principles/rules/solid/dip.ts +47 -0
- package/principles/rules/solid/isp.ts +37 -0
- package/principles/rules/solid/lsp.ts +79 -0
- package/principles/rules/solid/ocp.ts +40 -0
- package/principles/rules/solid/srp.ts +44 -0
- package/principles/types.ts +27 -0
- package/skills/delphi-review/AGENTS.md +7 -7
- package/skills/delphi-review/INSTALL.md +1 -1
- package/skills/delphi-review/SKILL.md +12 -12
- package/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/skills/sprint-flow/AGENTS.md +83 -36
- package/skills/sprint-flow/SKILL.md +1 -1
- package/skills/sprint-flow/references/force-levels.md +1 -1
- package/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import {
|
|
5
|
+
GateMOptions,
|
|
6
|
+
GateMResult,
|
|
7
|
+
GateMStatus,
|
|
8
|
+
MutationBaseline,
|
|
9
|
+
MutationScore,
|
|
10
|
+
FileThreshold,
|
|
11
|
+
ScoreEvaluation,
|
|
12
|
+
TestIntentCheckResult
|
|
13
|
+
} from './types';
|
|
14
|
+
import { StrykerReport } from './stryker-types';
|
|
15
|
+
import { detectAITestCharacteristics } from './detect-ai-test';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_THRESHOLD = 60;
|
|
18
|
+
const CRITICAL_PATH_THRESHOLD = 80;
|
|
19
|
+
const STRYKER_REPORT_PATH = '.stryker-report.json';
|
|
20
|
+
const STRYKER_CONFIG = 'stryker.prepush.conf.json';
|
|
21
|
+
|
|
22
|
+
type ArgHandler = (options: GateMOptions, args: string[], i: number) => void;
|
|
23
|
+
|
|
24
|
+
const ARG_HANDLERS: Record<string, ArgHandler> = {
|
|
25
|
+
'--changed-files': parseChangedFiles,
|
|
26
|
+
'--baseline': parseBaseline,
|
|
27
|
+
'--critical-paths': parseCriticalPaths,
|
|
28
|
+
'--timeout': parseTimeout
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function parseArgs(args: string[]): GateMOptions {
|
|
32
|
+
const options: GateMOptions = {
|
|
33
|
+
changedFiles: [],
|
|
34
|
+
baselinePath: '.mutation-baseline.json',
|
|
35
|
+
criticalPathsPath: '.mutation-critical-paths',
|
|
36
|
+
timeoutMs: 120000
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < args.length; i++) {
|
|
40
|
+
const handler = ARG_HANDLERS[args[i]];
|
|
41
|
+
if (handler) {
|
|
42
|
+
handler(options, args, i);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return options;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseChangedFiles(options: GateMOptions, args: string[], i: number): void {
|
|
50
|
+
const next = args[++i];
|
|
51
|
+
if (next) {
|
|
52
|
+
options.changedFiles = next.split(',').map(f => f.trim()).filter(Boolean);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseBaseline(options: GateMOptions, args: string[], i: number): void {
|
|
57
|
+
const next = args[++i];
|
|
58
|
+
if (next) options.baselinePath = next;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseCriticalPaths(options: GateMOptions, args: string[], i: number): void {
|
|
62
|
+
const next = args[++i];
|
|
63
|
+
if (next) options.criticalPathsPath = next;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseTimeout(options: GateMOptions, args: string[], i: number): void {
|
|
67
|
+
const next = args[++i];
|
|
68
|
+
if (next) options.timeoutMs = parseInt(next, 10);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function filterSourceFiles(files: string[]): string[] {
|
|
72
|
+
return files.filter(file => {
|
|
73
|
+
if (!file.endsWith('.ts')) return false;
|
|
74
|
+
if (file.endsWith('.test.ts')) return false;
|
|
75
|
+
if (file.endsWith('.d.ts')) return false;
|
|
76
|
+
if (file.includes('/adapters/')) return false;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
82
|
+
try {
|
|
83
|
+
await fs.access(filePath);
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function findTestFile(sourceFile: string): Promise<string | null> {
|
|
91
|
+
const testFile1 = sourceFile.replace(/\.ts$/, '.test.ts');
|
|
92
|
+
if (await fileExists(testFile1)) return testFile1;
|
|
93
|
+
|
|
94
|
+
const dir = path.dirname(sourceFile);
|
|
95
|
+
const basename = path.basename(sourceFile, '.ts');
|
|
96
|
+
const testFile2 = path.join(dir, '__tests__', `${basename}.test.ts`);
|
|
97
|
+
if (await fileExists(testFile2)) return testFile2;
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function checkTestIntents(
|
|
103
|
+
sourceFiles: string[]
|
|
104
|
+
): Promise<TestIntentCheckResult[]> {
|
|
105
|
+
const results: TestIntentCheckResult[] = [];
|
|
106
|
+
|
|
107
|
+
for (const sourceFile of sourceFiles) {
|
|
108
|
+
const testFile = await findTestFile(sourceFile);
|
|
109
|
+
if (!testFile) {
|
|
110
|
+
results.push({
|
|
111
|
+
sourceFile,
|
|
112
|
+
testFile: null,
|
|
113
|
+
hasTestAnnotation: false,
|
|
114
|
+
hasIntentAnnotation: false,
|
|
115
|
+
hasCoversAnnotation: false,
|
|
116
|
+
missingAnnotations: ['@test', '@intent', '@covers']
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const detection = await detectAITestCharacteristics(testFile);
|
|
122
|
+
const missingAnnotations: string[] = [];
|
|
123
|
+
if (!detection.annotations.hasTest) missingAnnotations.push('@test');
|
|
124
|
+
if (!detection.annotations.hasIntent) missingAnnotations.push('@intent');
|
|
125
|
+
if (!detection.annotations.hasCovers) missingAnnotations.push('@covers');
|
|
126
|
+
|
|
127
|
+
results.push({
|
|
128
|
+
sourceFile,
|
|
129
|
+
testFile,
|
|
130
|
+
hasTestAnnotation: detection.annotations.hasTest,
|
|
131
|
+
hasIntentAnnotation: detection.annotations.hasIntent,
|
|
132
|
+
hasCoversAnnotation: detection.annotations.hasCovers,
|
|
133
|
+
missingAnnotations
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function loadCriticalPaths(configPath: string): Promise<string[]> {
|
|
141
|
+
try {
|
|
142
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
143
|
+
return content
|
|
144
|
+
.split('\n')
|
|
145
|
+
.map(line => line.trim())
|
|
146
|
+
.filter(line => line.length > 0 && !line.startsWith('#'));
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function globToRegex(pattern: string): RegExp {
|
|
153
|
+
let regexStr = '';
|
|
154
|
+
let i = 0;
|
|
155
|
+
while (i < pattern.length) {
|
|
156
|
+
const c = pattern[i];
|
|
157
|
+
if (c === '*') {
|
|
158
|
+
if (pattern[i + 1] === '*') {
|
|
159
|
+
regexStr += '.*';
|
|
160
|
+
i += 2;
|
|
161
|
+
} else {
|
|
162
|
+
regexStr += '[^/]*';
|
|
163
|
+
i++;
|
|
164
|
+
}
|
|
165
|
+
} else if (c === '?') {
|
|
166
|
+
regexStr += '[^/]';
|
|
167
|
+
i++;
|
|
168
|
+
} else if ('.[]{}()|^$+\\'.includes(c)) {
|
|
169
|
+
regexStr += '\\' + c;
|
|
170
|
+
i++;
|
|
171
|
+
} else {
|
|
172
|
+
regexStr += c;
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return new RegExp(`^${regexStr}$`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isCriticalPath(file: string, patterns: string[]): boolean {
|
|
180
|
+
return patterns.some(pattern => globToRegex(pattern).test(file));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function loadBaseline(baselinePath: string): Promise<MutationBaseline | null> {
|
|
184
|
+
try {
|
|
185
|
+
const content = await fs.readFile(baselinePath, 'utf-8');
|
|
186
|
+
const parsed = JSON.parse(content) as MutationBaseline;
|
|
187
|
+
if (
|
|
188
|
+
typeof parsed.version === 'string' &&
|
|
189
|
+
typeof parsed.scores === 'object' &&
|
|
190
|
+
parsed.scores !== null
|
|
191
|
+
) {
|
|
192
|
+
return parsed;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function determineThresholds(
|
|
201
|
+
sourceFiles: string[],
|
|
202
|
+
criticalPaths: string[]
|
|
203
|
+
): Promise<FileThreshold[]> {
|
|
204
|
+
const thresholds: FileThreshold[] = [];
|
|
205
|
+
|
|
206
|
+
for (const file of sourceFiles) {
|
|
207
|
+
const testFile = await findTestFile(file);
|
|
208
|
+
let explicitThreshold: number | undefined;
|
|
209
|
+
if (testFile) {
|
|
210
|
+
const detection = await detectAITestCharacteristics(testFile);
|
|
211
|
+
explicitThreshold = detection.explicitThreshold;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const isCritical = isCriticalPath(file, criticalPaths);
|
|
215
|
+
const threshold = explicitThreshold !== undefined
|
|
216
|
+
? explicitThreshold
|
|
217
|
+
: isCritical
|
|
218
|
+
? CRITICAL_PATH_THRESHOLD
|
|
219
|
+
: DEFAULT_THRESHOLD;
|
|
220
|
+
|
|
221
|
+
thresholds.push({
|
|
222
|
+
file,
|
|
223
|
+
threshold,
|
|
224
|
+
isCriticalPath: isCritical,
|
|
225
|
+
explicitThreshold
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return thresholds;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function runStryker(
|
|
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
|
+
});
|
|
249
|
+
|
|
250
|
+
let stdout = '';
|
|
251
|
+
let stderr = '';
|
|
252
|
+
|
|
253
|
+
child.stdout?.on('data', (data: Buffer) => {
|
|
254
|
+
stdout += data.toString();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
child.stderr?.on('data', (data: Buffer) => {
|
|
258
|
+
stderr += data.toString();
|
|
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;
|
|
296
|
+
}
|
|
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
|
+
|
|
307
|
+
if (parsed.files && typeof parsed.files === 'object') {
|
|
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;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function evaluateScores(
|
|
334
|
+
report: StrykerReport,
|
|
335
|
+
thresholds: FileThreshold[],
|
|
336
|
+
baseline: MutationBaseline | null
|
|
337
|
+
): { evaluations: ScoreEvaluation[]; blocked: boolean; messages: string[] } {
|
|
338
|
+
const evaluations: ScoreEvaluation[] = [];
|
|
339
|
+
const messages: string[] = [];
|
|
340
|
+
let blocked = false;
|
|
341
|
+
|
|
342
|
+
for (const ft of thresholds) {
|
|
343
|
+
const result = evaluateFileThreshold(ft, report, baseline);
|
|
344
|
+
evaluations.push(result.evaluation);
|
|
345
|
+
if (!result.evaluation.passed) blocked = true;
|
|
346
|
+
messages.push(result.message);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { evaluations, blocked, messages };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function evaluateFileThreshold(
|
|
353
|
+
ft: FileThreshold,
|
|
354
|
+
report: StrykerReport,
|
|
355
|
+
baseline: MutationBaseline | null
|
|
356
|
+
): { evaluation: ScoreEvaluation; message: string } {
|
|
357
|
+
const fileReport = report.files
|
|
358
|
+
? Object.entries(report.files).find(([key]) => key === ft.file)?.[1]
|
|
359
|
+
: undefined;
|
|
360
|
+
|
|
361
|
+
const score = fileReport?.mutationScore ?? report.mutationScore;
|
|
362
|
+
const baselineScore = baseline?.scores[ft.file]?.score;
|
|
363
|
+
|
|
364
|
+
let effectiveThreshold = ft.threshold;
|
|
365
|
+
if (baselineScore !== undefined && score < baselineScore) {
|
|
366
|
+
effectiveThreshold = Math.max(effectiveThreshold, baselineScore);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const passed = score >= effectiveThreshold;
|
|
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 };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
interface ScoreMessageParams {
|
|
387
|
+
file: string;
|
|
388
|
+
score: number;
|
|
389
|
+
effectiveThreshold: number;
|
|
390
|
+
baselineScore: number | undefined;
|
|
391
|
+
passed: boolean;
|
|
392
|
+
isRegression: boolean;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildScoreMessage(params: ScoreMessageParams): string {
|
|
396
|
+
const { file, score, effectiveThreshold, baselineScore, passed, isRegression } = params;
|
|
397
|
+
if (!passed) {
|
|
398
|
+
if (isRegression && baselineScore !== undefined) {
|
|
399
|
+
return `BLOCK ${file}: mutation score ${score.toFixed(1)}% < baseline ${baselineScore.toFixed(1)}% (was ${effectiveThreshold}% threshold)`;
|
|
400
|
+
}
|
|
401
|
+
return `BLOCK ${file}: mutation score ${score.toFixed(1)}% < threshold ${effectiveThreshold}%`;
|
|
402
|
+
}
|
|
403
|
+
return `PASS ${file}: mutation score ${score.toFixed(1)}% >= ${effectiveThreshold}%${baselineScore !== undefined ? ` (baseline: ${baselineScore.toFixed(1)}%)` : ''}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function buildResult(
|
|
407
|
+
status: GateMStatus,
|
|
408
|
+
filesChecked: number,
|
|
409
|
+
scores: Record<string, MutationScore>,
|
|
410
|
+
warnings: string[],
|
|
411
|
+
errors: string[]
|
|
412
|
+
): GateMResult {
|
|
413
|
+
return {
|
|
414
|
+
exitCode: status === 'block' ? 1 : 0,
|
|
415
|
+
status,
|
|
416
|
+
filesChecked,
|
|
417
|
+
scores,
|
|
418
|
+
warnings,
|
|
419
|
+
errors
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function runGateM(options: GateMOptions): Promise<GateMResult> {
|
|
424
|
+
const warnings: string[] = [];
|
|
425
|
+
const errors: string[] = [];
|
|
426
|
+
|
|
427
|
+
const sourceFiles = filterSourceFiles(options.changedFiles);
|
|
428
|
+
|
|
429
|
+
if (sourceFiles.length === 0) {
|
|
430
|
+
return buildResult('skip', 0, {}, warnings, errors);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
collectTestIntentWarnings(sourceFiles, warnings);
|
|
434
|
+
|
|
435
|
+
const thresholds = await determineThresholdsWithLogging(options, sourceFiles);
|
|
436
|
+
const baseline = await loadBaselineWithLogging(options.baselinePath);
|
|
437
|
+
|
|
438
|
+
const strykerResult = await runStryker(sourceFiles, options.timeoutMs);
|
|
439
|
+
const timeoutResult = handleTimeout(strykerResult, options, sourceFiles, warnings, errors);
|
|
440
|
+
if (timeoutResult) return timeoutResult;
|
|
441
|
+
|
|
442
|
+
const errorResult = handleStrykerError(strykerResult, sourceFiles, warnings, errors);
|
|
443
|
+
if (errorResult) return errorResult;
|
|
444
|
+
|
|
445
|
+
const reportResult = handleMissingReport(strykerResult, sourceFiles, warnings, errors);
|
|
446
|
+
if (reportResult) return reportResult;
|
|
447
|
+
|
|
448
|
+
const evalResult = evaluateAndReport(strykerResult.report, thresholds, baseline);
|
|
449
|
+
|
|
450
|
+
if (evalResult.blocked) {
|
|
451
|
+
return buildResult('block', sourceFiles.length, evalResult.scores, warnings, errors);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return buildResult('pass', sourceFiles.length, evalResult.scores, warnings, errors);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function collectTestIntentWarnings(sourceFiles: string[], warnings: string[]): void {
|
|
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
|
+
}
|
|
469
|
+
|
|
470
|
+
async function determineThresholdsWithLogging(
|
|
471
|
+
options: GateMOptions,
|
|
472
|
+
sourceFiles: string[]
|
|
473
|
+
): Promise<FileThreshold[]> {
|
|
474
|
+
const criticalPaths = await loadCriticalPaths(options.criticalPathsPath);
|
|
475
|
+
const thresholds = await determineThresholds(sourceFiles, criticalPaths);
|
|
476
|
+
|
|
477
|
+
for (const ft of thresholds) {
|
|
478
|
+
const level = ft.isCriticalPath ? 'critical path' : 'default';
|
|
479
|
+
const thresholdSource = ft.explicitThreshold !== undefined
|
|
480
|
+
? 'explicit annotation'
|
|
481
|
+
: ft.isCriticalPath
|
|
482
|
+
? 'critical path config'
|
|
483
|
+
: 'default';
|
|
484
|
+
console.log(` ${ft.file}: threshold=${ft.threshold}% (${level}, ${thresholdSource})`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return thresholds;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function loadBaselineWithLogging(baselinePath: string): Promise<MutationBaseline | null> {
|
|
491
|
+
const baseline = await loadBaseline(baselinePath);
|
|
492
|
+
if (baseline) {
|
|
493
|
+
console.log(` Loaded baseline from ${baselinePath} (${Object.keys(baseline.scores).length} files)`);
|
|
494
|
+
}
|
|
495
|
+
return baseline;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function handleTimeout(
|
|
499
|
+
strykerResult: { report: StrykerReport | null; timedOut: boolean; error?: string },
|
|
500
|
+
options: GateMOptions,
|
|
501
|
+
sourceFiles: string[],
|
|
502
|
+
warnings: string[],
|
|
503
|
+
errors: string[]
|
|
504
|
+
): GateMResult | null {
|
|
505
|
+
if (!strykerResult.timedOut) return null;
|
|
506
|
+
|
|
507
|
+
warnings.push(
|
|
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
|
+
}
|
|
512
|
+
|
|
513
|
+
function handleStrykerError(
|
|
514
|
+
strykerResult: { report: StrykerReport | null; timedOut: boolean; error?: string },
|
|
515
|
+
sourceFiles: string[],
|
|
516
|
+
warnings: string[],
|
|
517
|
+
errors: string[]
|
|
518
|
+
): GateMResult | null {
|
|
519
|
+
if (!strykerResult.error || strykerResult.report) return null;
|
|
520
|
+
|
|
521
|
+
errors.push(`Stryker failed: ${strykerResult.error}`);
|
|
522
|
+
return buildResult('block', sourceFiles.length, {}, warnings, errors);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function handleMissingReport(
|
|
526
|
+
strykerResult: { report: StrykerReport | null; timedOut: boolean; error?: string },
|
|
527
|
+
sourceFiles: string[],
|
|
528
|
+
warnings: string[],
|
|
529
|
+
errors: string[]
|
|
530
|
+
): GateMResult | null {
|
|
531
|
+
if (strykerResult.report) return null;
|
|
532
|
+
|
|
533
|
+
errors.push('Stryker report not found or invalid.');
|
|
534
|
+
return buildResult('block', sourceFiles.length, {}, warnings, errors);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function evaluateAndReport(
|
|
538
|
+
report: StrykerReport | null,
|
|
539
|
+
thresholds: FileThreshold[],
|
|
540
|
+
baseline: MutationBaseline | null
|
|
541
|
+
): { scores: Record<string, MutationScore>; blocked: boolean; messages: string[] } {
|
|
542
|
+
if (!report) {
|
|
543
|
+
return { scores: {}, blocked: true, messages: ['Stryker report unavailable'] };
|
|
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
|
+
};
|
|
555
|
+
}
|
|
556
|
+
}
|
|
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
|
+
}
|
|
566
|
+
|
|
567
|
+
export async function main(args: string[]): Promise<number> {
|
|
568
|
+
const options = parseArgs(args);
|
|
569
|
+
|
|
570
|
+
if (options.changedFiles.length === 0) {
|
|
571
|
+
console.error('Usage: npx tsx src/mutation/gate-m.ts --changed-files "file1.ts,file2.ts" [--baseline <path>] [--critical-paths <path>]');
|
|
572
|
+
return 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
console.log(`Gate M: Mutation Testing`);
|
|
576
|
+
console.log(` Changed files: ${options.changedFiles.length}`);
|
|
577
|
+
console.log(` Baseline: ${options.baselinePath}`);
|
|
578
|
+
console.log(` Critical paths: ${options.criticalPathsPath}`);
|
|
579
|
+
console.log(` Timeout: ${options.timeoutMs}ms`);
|
|
580
|
+
|
|
581
|
+
const result = await runGateM(options);
|
|
582
|
+
|
|
583
|
+
if (result.warnings.length > 0) {
|
|
584
|
+
console.log('\nWarnings:');
|
|
585
|
+
for (const warning of result.warnings) {
|
|
586
|
+
console.log(` ⚠ ${warning}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (result.errors.length > 0) {
|
|
591
|
+
console.log('\nErrors:');
|
|
592
|
+
for (const error of result.errors) {
|
|
593
|
+
console.error(` ✗ ${error}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const statusIcon = result.status === 'pass' ? '✓' :
|
|
598
|
+
result.status === 'skip' ? '⊘' :
|
|
599
|
+
result.status === 'timeout' ? '⏱' : '✗';
|
|
600
|
+
|
|
601
|
+
console.log(`\n${statusIcon} Gate M ${result.status.toUpperCase()} (${result.filesChecked} files checked)`);
|
|
602
|
+
|
|
603
|
+
return result.exitCode;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const args = process.argv.slice(2);
|
|
607
|
+
if (typeof require !== 'undefined' && require.main === module) {
|
|
608
|
+
main(args)
|
|
609
|
+
.then(exitCode => {
|
|
610
|
+
if (exitCode !== 0) {
|
|
611
|
+
process.exit(exitCode);
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
.catch(err => {
|
|
615
|
+
console.error('Gate M failed:', err.message);
|
|
616
|
+
process.exit(1);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
// trigger Gate M
|