@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.
Files changed (141) hide show
  1. package/bin/xp-gate.js +18 -0
  2. package/hooks/pre-commit +565 -120
  3. package/lib/arch.js +49 -0
  4. package/lib/check.js +50 -0
  5. package/lib/init.js +36 -0
  6. package/lib/principles.js +48 -0
  7. package/mock-policy/AGENTS.md +94 -0
  8. package/mock-policy/__tests__/config.test.ts +292 -0
  9. package/mock-policy/__tests__/integration.test.ts +600 -0
  10. package/mock-policy/__tests__/mock-decision-engine.test.ts +231 -0
  11. package/mock-policy/__tests__/scope-scanner.test.ts +535 -0
  12. package/mock-policy/config.ts +106 -0
  13. package/mock-policy/gate-m3.ts +233 -0
  14. package/mock-policy/mock-decision-engine.ts +155 -0
  15. package/mock-policy/schema.ts +22 -0
  16. package/mock-policy/scope-scanner.ts +288 -0
  17. package/mock-policy/types.ts +65 -0
  18. package/mutation/AGENTS.md +73 -0
  19. package/mutation/__tests__/detect-ai-test.test.ts +152 -0
  20. package/mutation/__tests__/gate-m.test.ts +194 -0
  21. package/mutation/detect-ai-test.ts +128 -0
  22. package/mutation/gate-m.ts +619 -0
  23. package/mutation/init-baseline.ts +192 -0
  24. package/mutation/stryker-types.ts +14 -0
  25. package/mutation/types.ts +70 -0
  26. package/mutation/update-baseline.ts +160 -0
  27. package/package.json +5 -2
  28. package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
  29. package/plugins/claude-code/skills/delphi-review/AGENTS.md +7 -7
  30. package/plugins/claude-code/skills/delphi-review/INSTALL.md +1 -1
  31. package/plugins/claude-code/skills/delphi-review/SKILL.md +12 -12
  32. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +1 -1
  33. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +83 -36
  34. package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
  35. package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +1 -1
  36. package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +2 -2
  37. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  38. package/plugins/opencode/README.md +23 -7
  39. package/plugins/opencode/index.ts +36 -21
  40. package/plugins/opencode/package.json +1 -1
  41. package/plugins/opencode/skills/delphi-review/AGENTS.md +7 -7
  42. package/plugins/opencode/skills/delphi-review/INSTALL.md +1 -1
  43. package/plugins/opencode/skills/delphi-review/SKILL.md +12 -12
  44. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +1 -1
  45. package/plugins/opencode/skills/sprint-flow/AGENTS.md +83 -36
  46. package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
  47. package/plugins/opencode/skills/sprint-flow/references/force-levels.md +1 -1
  48. package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +2 -2
  49. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  50. package/plugins/qoder/plugin.json +20 -0
  51. package/plugins/qoder/skills/delphi-review/AGENTS.md +7 -7
  52. package/plugins/qoder/skills/delphi-review/INSTALL.md +1 -1
  53. package/plugins/qoder/skills/delphi-review/SKILL.md +12 -12
  54. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +1 -1
  55. package/plugins/qoder/skills/sprint-flow/AGENTS.md +83 -36
  56. package/plugins/qoder/skills/sprint-flow/SKILL.md +51 -1
  57. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +1 -1
  58. package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +2 -2
  59. package/plugins/qoder/skills/test-driven-development/SKILL.md +371 -0
  60. package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +299 -0
  61. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  62. package/principles/AGENTS.md +111 -0
  63. package/principles/__tests__/analyzer.test.ts +343 -0
  64. package/principles/__tests__/baseline-storage.test.ts +187 -0
  65. package/principles/__tests__/baseline.test.ts +328 -0
  66. package/principles/__tests__/boy-scout-integration.test.ts +264 -0
  67. package/principles/__tests__/boy-scout.test.ts +646 -0
  68. package/principles/__tests__/config.test.ts +78 -0
  69. package/principles/__tests__/index.test.ts +81 -0
  70. package/principles/__tests__/lint-baseline.test.ts +314 -0
  71. package/principles/__tests__/reporter.test.ts +832 -0
  72. package/principles/__tests__/types.test.ts +101 -0
  73. package/principles/adapters/__tests__/base.test.ts +95 -0
  74. package/principles/adapters/__tests__/cpp.test.ts +290 -0
  75. package/principles/adapters/__tests__/dart.test.ts +84 -0
  76. package/principles/adapters/__tests__/go.test.ts +68 -0
  77. package/principles/adapters/__tests__/java.test.ts +68 -0
  78. package/principles/adapters/__tests__/kotlin.test.ts +76 -0
  79. package/principles/adapters/__tests__/objectivec.test.ts +453 -0
  80. package/principles/adapters/__tests__/python.test.ts +84 -0
  81. package/principles/adapters/__tests__/swift.test.ts +68 -0
  82. package/principles/adapters/__tests__/typescript.test.ts +116 -0
  83. package/principles/adapters/base.ts +124 -0
  84. package/principles/adapters/cpp.ts +115 -0
  85. package/principles/adapters/dart.ts +44 -0
  86. package/principles/adapters/go.ts +55 -0
  87. package/principles/adapters/java.ts +56 -0
  88. package/principles/adapters/kotlin.ts +54 -0
  89. package/principles/adapters/objectivec.ts +82 -0
  90. package/principles/adapters/python.ts +83 -0
  91. package/principles/adapters/swift.ts +40 -0
  92. package/principles/adapters/typescript.ts +66 -0
  93. package/principles/analyzer.ts +190 -0
  94. package/principles/baseline.ts +273 -0
  95. package/principles/boy-scout.ts +416 -0
  96. package/principles/config.ts +118 -0
  97. package/principles/index.ts +87 -0
  98. package/principles/lint-baseline.ts +385 -0
  99. package/principles/reporter.ts +188 -0
  100. package/principles/rules/__tests__/clean-code/code-duplication.test.ts +84 -0
  101. package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +74 -0
  102. package/principles/rules/__tests__/clean-code/god-class.test.ts +111 -0
  103. package/principles/rules/__tests__/clean-code/large-file.test.ts +73 -0
  104. package/principles/rules/__tests__/clean-code/long-function.test.ts +83 -0
  105. package/principles/rules/__tests__/clean-code/magic-numbers.test.ts +68 -0
  106. package/principles/rules/__tests__/clean-code/many-exports.test.ts +118 -0
  107. package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +173 -0
  108. package/principles/rules/__tests__/clean-code/too-many-params.test.ts +60 -0
  109. package/principles/rules/__tests__/clean-code/unused-imports.test.ts +107 -0
  110. package/principles/rules/__tests__/index.test.ts +49 -0
  111. package/principles/rules/__tests__/solid/dip.test.ts +121 -0
  112. package/principles/rules/__tests__/solid/isp.test.ts +60 -0
  113. package/principles/rules/__tests__/solid/lsp.test.ts +184 -0
  114. package/principles/rules/__tests__/solid/ocp.test.ts +69 -0
  115. package/principles/rules/__tests__/solid/srp.test.ts +89 -0
  116. package/principles/rules/clean-code/code-duplication.ts +31 -0
  117. package/principles/rules/clean-code/deep-nesting.ts +38 -0
  118. package/principles/rules/clean-code/god-class.ts +54 -0
  119. package/principles/rules/clean-code/large-file.ts +34 -0
  120. package/principles/rules/clean-code/long-function.ts +47 -0
  121. package/principles/rules/clean-code/magic-numbers.ts +59 -0
  122. package/principles/rules/clean-code/many-exports.ts +40 -0
  123. package/principles/rules/clean-code/missing-error-handling.ts +33 -0
  124. package/principles/rules/clean-code/too-many-params.ts +35 -0
  125. package/principles/rules/clean-code/unused-imports.ts +33 -0
  126. package/principles/rules/index.ts +61 -0
  127. package/principles/rules/solid/dip.ts +47 -0
  128. package/principles/rules/solid/isp.ts +37 -0
  129. package/principles/rules/solid/lsp.ts +79 -0
  130. package/principles/rules/solid/ocp.ts +40 -0
  131. package/principles/rules/solid/srp.ts +44 -0
  132. package/principles/types.ts +27 -0
  133. package/skills/delphi-review/AGENTS.md +7 -7
  134. package/skills/delphi-review/INSTALL.md +1 -1
  135. package/skills/delphi-review/SKILL.md +12 -12
  136. package/skills/delphi-review/references/code-walkthrough.md +1 -1
  137. package/skills/sprint-flow/AGENTS.md +83 -36
  138. package/skills/sprint-flow/SKILL.md +1 -1
  139. package/skills/sprint-flow/references/force-levels.md +1 -1
  140. package/skills/sprint-flow/references/phase-3-review.md +2 -2
  141. 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