jules-orchestrator-kit 0.41.1 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { resolve, isAbsolute } from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { diffText } from "./git.mjs";
6
+
7
+ /**
8
+ * @typedef {Object} MutationCandidate
9
+ * @property {string} id - Unique mutant identifier (file:line:operator)
10
+ * @property {string} file - Relative path of target file
11
+ * @property {number} line - 1-indexed line number
12
+ * @property {string} originalLine - Original line content
13
+ * @property {string} mutatedLine - Mutated line content
14
+ * @property {string} mutationType - Category of mutation (e.g., 'EQUALITY', 'LOGICAL', 'RELATIONAL', 'ARITHMETIC', 'BOOLEAN', 'RETURN')
15
+ * @property {string} description - Human-readable explanation of mutation
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} MutantResult
20
+ * @property {MutationCandidate} mutant - The evaluated mutant
21
+ * @property {"KILLED" | "SURVIVED" | "TIMEOUT" | "ERROR"} status - Outcome of mutant execution
22
+ * @property {number} exitCode - Exit code from test command
23
+ * @property {number} durationMs - Execution time in milliseconds
24
+ * @property {string} [stdout] - Test command stdout
25
+ * @property {string} [stderr] - Test command stderr
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} MutationReport
30
+ * @property {boolean} ok - True if mutation score meets or exceeds minScore
31
+ * @property {number} totalMutants - Total candidate mutants tested
32
+ * @property {number} killedMutants - Number of mutants killed (test failed as expected)
33
+ * @property {number} survivedMutants - Number of mutants survived (test passed despite mutation)
34
+ * @property {number} errorMutants - Number of mutants with execution errors/timeouts
35
+ * @property {number} mutationScore - Percentage of killed mutants (0 - 100)
36
+ * @property {number} minScore - Required threshold score
37
+ * @property {MutantResult[]} results - Detailed results for all mutants
38
+ * @property {MutantResult[]} survivors - List of survived mutants for remediation
39
+ * @property {number} durationMs - Total run duration in milliseconds
40
+ */
41
+
42
+ /**
43
+ * Operator mutation replacement rules.
44
+ * Sorted to prevent premature substring collision (e.g. `===` before `==`).
45
+ */
46
+ export const MUTATION_RULES = [
47
+ // 1. Strict & Loose Equality
48
+ {
49
+ type: "EQUALITY",
50
+ pattern: /===/g,
51
+ replace: "!==",
52
+ desc: "Inverted strict equality (=== -> !==)",
53
+ },
54
+ {
55
+ type: "EQUALITY",
56
+ pattern: /!==/g,
57
+ replace: "===",
58
+ desc: "Inverted strict inequality (!== -> ===)",
59
+ },
60
+ {
61
+ type: "EQUALITY",
62
+ // Ensure we don't match === or !==
63
+ pattern: /(?<![=!])==(?![=])/g,
64
+ replace: "!=",
65
+ desc: "Inverted loose equality (== -> !=)",
66
+ },
67
+ {
68
+ type: "EQUALITY",
69
+ pattern: /(?<![=!])!=(?![=])/g,
70
+ replace: "==",
71
+ desc: "Inverted loose inequality (!= -> ==)",
72
+ },
73
+
74
+ // 2. Relational Operators
75
+ {
76
+ type: "RELATIONAL",
77
+ pattern: />=/g,
78
+ replace: "<",
79
+ desc: "Inverted relational operator (>= -> <)",
80
+ },
81
+ {
82
+ type: "RELATIONAL",
83
+ // Exclude arrow functions (<= is relational, => is arrow)
84
+ pattern: /<=(?!>)/g,
85
+ replace: ">",
86
+ desc: "Inverted relational operator (<= -> >)",
87
+ },
88
+ {
89
+ type: "RELATIONAL",
90
+ // Exclude =>, >>, ->, and closing XML/HTML tags
91
+ pattern: /(?<![=-])>(?![=>])/g,
92
+ replace: "<=",
93
+ desc: "Inverted comparison (> -> <=)",
94
+ },
95
+ {
96
+ type: "RELATIONAL",
97
+ // Exclude <<, <-, and HTML/XML tag openings (<foo)
98
+ pattern: /(?<![<])<(?![<=/a-zA-Z!])/g,
99
+ replace: ">=",
100
+ desc: "Inverted comparison (< -> >=)",
101
+ },
102
+
103
+ // 3. Logical Operators
104
+ {
105
+ type: "LOGICAL",
106
+ pattern: /&&/g,
107
+ replace: "||",
108
+ desc: "Swapped logical AND with OR (&& -> ||)",
109
+ },
110
+ {
111
+ type: "LOGICAL",
112
+ pattern: /\|\|/g,
113
+ replace: "&&",
114
+ desc: "Swapped logical OR with AND (|| -> &&)",
115
+ },
116
+
117
+ // 4. Boolean Literals
118
+ {
119
+ type: "BOOLEAN",
120
+ pattern: /\btrue\b/g,
121
+ replace: "false",
122
+ desc: "Inverted boolean literal (true -> false)",
123
+ },
124
+ {
125
+ type: "BOOLEAN",
126
+ pattern: /\bfalse\b/g,
127
+ replace: "true",
128
+ desc: "Inverted boolean literal (false -> true)",
129
+ },
130
+
131
+ // 5. Unary & Arithmetic
132
+ {
133
+ type: "ARITHMETIC",
134
+ pattern: /\+\+/g,
135
+ replace: "--",
136
+ desc: "Swapped increment with decrement (++ -> --)",
137
+ },
138
+ {
139
+ type: "ARITHMETIC",
140
+ pattern: /--/g,
141
+ replace: "++",
142
+ desc: "Swapped decrement with increment (-- -> ++)",
143
+ },
144
+ {
145
+ type: "ARITHMETIC",
146
+ pattern: /(\s)\+(\s)/g,
147
+ replace: "$1-$2",
148
+ desc: "Swapped addition with subtraction (+ -> -)",
149
+ },
150
+ {
151
+ type: "ARITHMETIC",
152
+ pattern: /(\s)-(?![\d>])(\s)/g,
153
+ replace: "$1+$2",
154
+ desc: "Swapped subtraction with addition (- -> +)",
155
+ },
156
+ {
157
+ type: "ARITHMETIC",
158
+ pattern: /(\s)\*(\s)/g,
159
+ replace: "$1/$2",
160
+ desc: "Swapped multiplication with division (* -> /)",
161
+ },
162
+ {
163
+ type: "ARITHMETIC",
164
+ pattern: /(\s)\/(\s)/g,
165
+ replace: "$1*$2",
166
+ desc: "Swapped division with multiplication (/ -> *)",
167
+ },
168
+
169
+ // 6. Return Statements
170
+ {
171
+ type: "RETURN",
172
+ pattern: /\breturn\s+true\b/g,
173
+ replace: "return false",
174
+ desc: "Inverted return boolean (return true -> return false)",
175
+ },
176
+ {
177
+ type: "RETURN",
178
+ pattern: /\breturn\s+false\b/g,
179
+ replace: "return true",
180
+ desc: "Inverted return boolean (return false -> return true)",
181
+ },
182
+ {
183
+ type: "RETURN",
184
+ pattern: /\breturn\s+null\b/g,
185
+ replace: "return undefined",
186
+ desc: "Altered return null (return null -> return undefined)",
187
+ },
188
+ ];
189
+
190
+ /**
191
+ * Checks if a file path is a test or non-implementation file that should be excluded from mutation.
192
+ * @param {string} filePath
193
+ * @returns {boolean}
194
+ */
195
+ export function isExcludedFromMutation(filePath = "") {
196
+ const normalized = filePath.replace(/\\/g, "/").toLowerCase();
197
+
198
+ // Exclude test files
199
+ if (
200
+ normalized.includes(".test.") ||
201
+ normalized.includes(".spec.") ||
202
+ normalized.includes("_test.") ||
203
+ normalized.includes("/test/") ||
204
+ normalized.includes("/tests/") ||
205
+ normalized.includes("/__tests__/")
206
+ ) {
207
+ return true;
208
+ }
209
+
210
+ // Exclude non-executable / config / documentation formats
211
+ const nonExecExtensions = [
212
+ ".md", ".markdown", ".json", ".yml", ".yaml", ".toml", ".txt",
213
+ ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2",
214
+ ".lock", ".lockb", ".css", ".scss", ".less", ".html",
215
+ ];
216
+
217
+ if (nonExecExtensions.some((ext) => normalized.endsWith(ext))) {
218
+ return true;
219
+ }
220
+
221
+ // Exclude package manifests and agent rules
222
+ if (
223
+ normalized.endsWith("package.json") ||
224
+ normalized.endsWith("package-lock.json") ||
225
+ normalized.includes(".agent/") ||
226
+ normalized.includes(".github/")
227
+ ) {
228
+ return true;
229
+ }
230
+
231
+ return false;
232
+ }
233
+
234
+ /**
235
+ * Returns a map of line number (1-indexed) to array of character index ranges [startCol, endCol]
236
+ * that are part of string literals (including multiline template literals and block comments).
237
+ * @param {string} sourceCode
238
+ * @returns {Map<number, Array<[number, number]>>}
239
+ */
240
+ export function getFileStringLiteralLineMap(sourceCode = "") {
241
+ const lines = sourceCode.split("\n");
242
+ const lineMap = new Map();
243
+ let inTemplate = false;
244
+ let inBlockComment = false;
245
+
246
+ for (let l = 0; l < lines.length; l++) {
247
+ const line = lines[l];
248
+ const ranges = [];
249
+ let inQuote = null;
250
+ let quoteStart = -1;
251
+ let escaped = false;
252
+
253
+ for (let i = 0; i < line.length; i++) {
254
+ const char = line[i];
255
+ if (escaped) {
256
+ escaped = false;
257
+ continue;
258
+ }
259
+ if (char === "\\") {
260
+ escaped = true;
261
+ continue;
262
+ }
263
+
264
+ if (inBlockComment) {
265
+ if (char === "*" && line[i + 1] === "/") {
266
+ inBlockComment = false;
267
+ i++;
268
+ }
269
+ continue;
270
+ }
271
+
272
+ if (inTemplate) {
273
+ if (char === "`") {
274
+ inTemplate = false;
275
+ ranges.push([0, i]);
276
+ }
277
+ continue;
278
+ }
279
+
280
+ if (inQuote) {
281
+ if (char === inQuote) {
282
+ ranges.push([quoteStart, i]);
283
+ inQuote = null;
284
+ }
285
+ } else {
286
+ if (char === "/" && line[i + 1] === "*") {
287
+ inBlockComment = true;
288
+ quoteStart = i;
289
+ i++;
290
+ continue;
291
+ }
292
+ if (char === "/" && line[i + 1] === "/") {
293
+ ranges.push([i, line.length - 1]);
294
+ break;
295
+ }
296
+ if (char === '"' || char === "'") {
297
+ inQuote = char;
298
+ quoteStart = i;
299
+ } else if (char === "`") {
300
+ inTemplate = true;
301
+ quoteStart = i;
302
+ }
303
+ }
304
+ }
305
+
306
+ if (inTemplate) {
307
+ ranges.push([quoteStart >= 0 ? quoteStart : 0, line.length - 1]);
308
+ quoteStart = 0;
309
+ } else if (inBlockComment) {
310
+ ranges.push([quoteStart >= 0 ? quoteStart : 0, line.length - 1]);
311
+ quoteStart = 0;
312
+ } else if (inQuote && quoteStart >= 0) {
313
+ ranges.push([quoteStart, line.length - 1]);
314
+ }
315
+
316
+ lineMap.set(l + 1, ranges);
317
+ }
318
+ return lineMap;
319
+ }
320
+
321
+ /**
322
+ * Returns character index ranges [start, end] for all string literals on a line.
323
+ * @param {string} line
324
+ * @returns {Array<[number, number]>}
325
+ */
326
+ export function getStringLiteralRanges(line) {
327
+ const ranges = [];
328
+ let inQuote = null;
329
+ let start = -1;
330
+ let escaped = false;
331
+
332
+ for (let i = 0; i < line.length; i++) {
333
+ const char = line[i];
334
+ if (escaped) {
335
+ escaped = false;
336
+ continue;
337
+ }
338
+ if (char === "\\") {
339
+ escaped = true;
340
+ continue;
341
+ }
342
+ if (inQuote) {
343
+ if (char === inQuote) {
344
+ ranges.push([start, i]);
345
+ inQuote = null;
346
+ }
347
+ } else {
348
+ if (char === '"' || char === "'" || char === "`") {
349
+ inQuote = char;
350
+ start = i;
351
+ }
352
+ }
353
+ }
354
+ if (inQuote && start !== -1) {
355
+ ranges.push([start, line.length - 1]);
356
+ }
357
+ return ranges;
358
+ }
359
+
360
+ /**
361
+ * Generates mutation candidates for a given single line of code.
362
+ * @param {string} line - Line text
363
+ * @param {number} lineNo - 1-indexed line number
364
+ * @param {string} filePath - Target file path
365
+ * @param {Array<[number, number]>} [explicitStringRanges] - Pre-calculated string ranges
366
+ * @returns {MutationCandidate[]}
367
+ */
368
+ export function generateLineMutants(line = "", lineNo = 1, filePath = "", explicitStringRanges = null) {
369
+ const trimmed = line.trim();
370
+ // Skip comments, imports, exports, and empty lines
371
+ if (
372
+ !trimmed ||
373
+ trimmed.startsWith("//") ||
374
+ trimmed.startsWith("/*") ||
375
+ trimmed.startsWith("*") ||
376
+ trimmed.startsWith("import ") ||
377
+ trimmed.startsWith("#") ||
378
+ trimmed.startsWith("from ") ||
379
+ trimmed.startsWith("package ")
380
+ ) {
381
+ return [];
382
+ }
383
+
384
+ const mutants = [];
385
+ const lineHash = createHash("sha256").update(line).digest("hex").slice(0, 6);
386
+ const stringRanges = Array.isArray(explicitStringRanges) ? explicitStringRanges : getStringLiteralRanges(line);
387
+
388
+ for (let ruleIdx = 0; ruleIdx < MUTATION_RULES.length; ruleIdx++) {
389
+ const rule = MUTATION_RULES[ruleIdx];
390
+ const regex = new RegExp(rule.pattern.source, rule.pattern.flags);
391
+
392
+ let match;
393
+ while ((match = regex.exec(line)) !== null) {
394
+ const matchIndex = match.index;
395
+ const matchedText = match[0];
396
+ if (matchedText.length === 0) {
397
+ regex.lastIndex++;
398
+ continue;
399
+ }
400
+
401
+ // Do not mutate operators inside string literals or comments
402
+ if (rule.type !== "BOOLEAN" && rule.type !== "RETURN") {
403
+ const isInsideString = stringRanges.some(([start, end]) => matchIndex >= start && matchIndex <= end);
404
+ if (isInsideString) {
405
+ if (!rule.pattern.global) break;
406
+ continue;
407
+ }
408
+ }
409
+
410
+ let replacement = rule.replace;
411
+ if (typeof replacement === "string" && replacement.includes("$")) {
412
+ const singleRegex = new RegExp(rule.pattern.source);
413
+ replacement = matchedText.replace(singleRegex, rule.replace);
414
+ }
415
+
416
+ const mutatedLine = line.slice(0, matchIndex) + replacement + line.slice(matchIndex + matchedText.length);
417
+
418
+ if (mutatedLine !== line) {
419
+ mutants.push({
420
+ id: `${filePath}:${lineNo}:m${ruleIdx}-${matchIndex}-${lineHash}`,
421
+ file: filePath,
422
+ line: lineNo,
423
+ originalLine: line,
424
+ mutatedLine,
425
+ mutationType: rule.type,
426
+ description: rule.desc,
427
+ });
428
+ }
429
+
430
+ if (!rule.pattern.global) break;
431
+ }
432
+ }
433
+
434
+ return mutants;
435
+ }
436
+
437
+ /**
438
+ * Generates mutation candidates for an entire file content.
439
+ * @param {string} sourceCode
440
+ * @param {string} filePath
441
+ * @returns {MutationCandidate[]}
442
+ */
443
+ export function generateMutants(sourceCode = "", filePath = "source.js") {
444
+ if (isExcludedFromMutation(filePath)) return [];
445
+
446
+ const lines = sourceCode.split("\n");
447
+ const stringMap = getFileStringLiteralLineMap(sourceCode);
448
+ const allMutants = [];
449
+
450
+ for (let i = 0; i < lines.length; i++) {
451
+ const lineRanges = stringMap.get(i + 1) || [];
452
+ const lineMutants = generateLineMutants(lines[i], i + 1, filePath, lineRanges);
453
+ allMutants.push(...lineMutants);
454
+ }
455
+
456
+ return allMutants;
457
+ }
458
+
459
+ /**
460
+ * Parses a unified diff string and extracts mutation candidates strictly from added lines (`+` hunks).
461
+ * @param {string} diffStr - Unified git diff
462
+ * @param {string} [root=process.cwd()] - Project root
463
+ * @returns {MutationCandidate[]}
464
+ */
465
+ export function generateDiffMutants(diffStr = "", root = process.cwd()) {
466
+ if (!diffStr) return [];
467
+
468
+ const candidates = [];
469
+ let currentFile = null;
470
+ let currentLineNo = null;
471
+ let currentFileStringMap = null;
472
+
473
+ const lines = diffStr.split("\n");
474
+ for (let i = 0; i < lines.length; i++) {
475
+ const line = lines[i];
476
+
477
+ if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
478
+ const target = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
479
+ currentFile = target && target !== "/dev/null" ? target : null;
480
+ currentLineNo = null;
481
+ currentFileStringMap = null;
482
+
483
+ if (currentFile && root) {
484
+ const fullPath = isAbsolute(currentFile) ? currentFile : resolve(root, currentFile);
485
+ if (existsSync(fullPath)) {
486
+ try {
487
+ const content = readFileSync(fullPath, "utf-8");
488
+ currentFileStringMap = getFileStringLiteralLineMap(content);
489
+ } catch (_) {}
490
+ }
491
+ }
492
+ continue;
493
+ }
494
+
495
+ const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
496
+ if (hunkMatch) {
497
+ currentLineNo = Number(hunkMatch[1]);
498
+ continue;
499
+ }
500
+
501
+ if (line.startsWith("+") && !line.startsWith("+++")) {
502
+ if (currentFile && !isExcludedFromMutation(currentFile) && currentLineNo !== null) {
503
+ const addedText = line.slice(1);
504
+ const lineRanges = currentFileStringMap ? currentFileStringMap.get(currentLineNo) : null;
505
+ const lineMutants = generateLineMutants(addedText, currentLineNo, currentFile, lineRanges);
506
+ candidates.push(...lineMutants);
507
+ }
508
+ if (currentLineNo !== null) currentLineNo++;
509
+ } else if (currentLineNo !== null && !line.startsWith("-") && !line.startsWith("\\")) {
510
+ currentLineNo++;
511
+ }
512
+ }
513
+
514
+ return candidates;
515
+ }
516
+
517
+ /**
518
+ * Applies a candidate mutation to a target file, executes the test command, and safely rolls back.
519
+ *
520
+ * @param {MutationCandidate} mutant
521
+ * @param {Object} [options]
522
+ * @param {string} [options.root=process.cwd()]
523
+ * @param {string} [options.testCmd="npm test"]
524
+ * @param {number} [options.timeoutMs=15000]
525
+ * @param {Function} [options.executor] - Optional custom execution function for mocking / speed
526
+ * @returns {MutantResult}
527
+ */
528
+ export function executeMutant(mutant, options = {}) {
529
+ const root = options.root || process.cwd();
530
+ const testCmd = options.testCmd || "npm test";
531
+ const timeoutMs = options.timeoutMs || 15000;
532
+ const absPath = isAbsolute(mutant.file) ? mutant.file : resolve(root, mutant.file);
533
+
534
+ if (!existsSync(absPath)) {
535
+ return {
536
+ mutant,
537
+ status: "ERROR",
538
+ exitCode: 1,
539
+ durationMs: 0,
540
+ stderr: `Target file does not exist: ${mutant.file}`,
541
+ };
542
+ }
543
+
544
+ const originalContent = readFileSync(absPath, "utf-8");
545
+ const lines = originalContent.split("\n");
546
+
547
+ if (mutant.line < 1 || mutant.line > lines.length) {
548
+ return {
549
+ mutant,
550
+ status: "ERROR",
551
+ exitCode: 1,
552
+ durationMs: 0,
553
+ stderr: `Mutant line ${mutant.line} out of range (1..${lines.length})`,
554
+ };
555
+ }
556
+
557
+ // Replace target line
558
+ lines[mutant.line - 1] = mutant.mutatedLine;
559
+ const mutatedContent = lines.join("\n");
560
+
561
+ const startTime = Date.now();
562
+ let exitCode = 0;
563
+ let stdout = "";
564
+ let stderr = "";
565
+ let status = "SURVIVED";
566
+
567
+ try {
568
+ // Write mutant to disk
569
+ writeFileSync(absPath, mutatedContent, "utf-8");
570
+
571
+ if (typeof options.executor === "function") {
572
+ const execResult = options.executor({ mutant, testCmd, absPath });
573
+ exitCode = typeof execResult.exitCode === "number" ? execResult.exitCode : execResult.status || 0;
574
+ stdout = execResult.stdout || "";
575
+ stderr = execResult.stderr || "";
576
+ } else {
577
+ try {
578
+ stdout = execSync(testCmd, {
579
+ cwd: root,
580
+ timeout: timeoutMs,
581
+ stdio: ["ignore", "pipe", "pipe"],
582
+ env: { ...process.env, CI: "true", JULES_MUTATION_RUN: "true" },
583
+ encoding: "utf-8",
584
+ });
585
+ exitCode = 0;
586
+ } catch (execErr) {
587
+ exitCode = execErr.status || execErr.statusCode || 1;
588
+ stdout = execErr.stdout ? execErr.stdout.toString() : "";
589
+ stderr = execErr.stderr ? execErr.stderr.toString() : execErr.message;
590
+ if (execErr.code === "ETIMEDOUT" || execErr.killed) {
591
+ status = "TIMEOUT";
592
+ }
593
+ }
594
+ }
595
+
596
+ const durationMs = Date.now() - startTime;
597
+
598
+ // If test failed (non-zero exit code) or timed out, mutant was KILLED
599
+ if (status === "TIMEOUT") {
600
+ return { mutant, status: "KILLED", exitCode, durationMs, stdout, stderr };
601
+ }
602
+
603
+ if (exitCode !== 0) {
604
+ status = "KILLED";
605
+ } else {
606
+ status = "SURVIVED";
607
+ }
608
+
609
+ return {
610
+ mutant,
611
+ status,
612
+ exitCode,
613
+ durationMs,
614
+ stdout: stdout.slice(0, 500),
615
+ stderr: stderr.slice(0, 500),
616
+ };
617
+ } catch (err) {
618
+ return {
619
+ mutant,
620
+ status: "ERROR",
621
+ exitCode: 1,
622
+ durationMs: Date.now() - startTime,
623
+ stderr: err.message,
624
+ };
625
+ } finally {
626
+ // GUARANTEED SAFE ROLLBACK
627
+ try {
628
+ writeFileSync(absPath, originalContent, "utf-8");
629
+ } catch (_) {}
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Runs a complete mutation test sweep across candidate mutants.
635
+ *
636
+ * @param {Object} [options]
637
+ * @param {string} [options.root=process.cwd()]
638
+ * @param {string} [options.diffStr] - Optional unified diff to target (defaults to git working tree diff)
639
+ * @param {string} [options.base="main"] - Base branch if computing diff
640
+ * @param {string} [options.mode="working-tree"] - Diff mode ("working-tree" | "staged" | "committed")
641
+ * @param {string} [options.testCmd="npm test"] - Test command
642
+ * @param {number} [options.minScore=80] - Minimum required mutation score (default 80%)
643
+ * @param {number} [options.maxMutants=20] - Maximum mutants to evaluate to prevent excessive CI duration
644
+ * @param {number} [options.timeoutMs=15000] - Per-mutant test timeout
645
+ * @param {Function} [options.executor] - Custom runner for unit testing
646
+ * @returns {MutationReport}
647
+ */
648
+ export function runMutationTest(options = {}) {
649
+ const root = options.root || process.cwd();
650
+ const base = options.base || "main";
651
+ const mode = options.mode || "working-tree";
652
+ const minScore = typeof options.minScore === "number" ? options.minScore : 80;
653
+ const maxMutants = typeof options.maxMutants === "number" ? options.maxMutants : 20;
654
+ const testCmd = options.testCmd || "npm test";
655
+
656
+ const startTime = Date.now();
657
+
658
+ let candidates = [];
659
+ if (Array.isArray(options.files) && options.files.length > 0) {
660
+ for (const relFile of options.files) {
661
+ const abs = resolve(root, relFile);
662
+ if (existsSync(abs)) {
663
+ const content = readFileSync(abs, "utf-8");
664
+ candidates.push(...generateMutants(content, relFile));
665
+ }
666
+ }
667
+ } else {
668
+ let diffContent = options.diffStr;
669
+ if (!diffContent) {
670
+ try {
671
+ diffContent = diffText(root, base, mode);
672
+ } catch (_) {
673
+ diffContent = "";
674
+ }
675
+ }
676
+ if (diffContent) {
677
+ candidates = generateDiffMutants(diffContent, root);
678
+ }
679
+ }
680
+
681
+ // Cap candidates to maxMutants
682
+ const selectedMutants = candidates.slice(0, maxMutants);
683
+
684
+ if (selectedMutants.length === 0) {
685
+ return {
686
+ ok: true,
687
+ totalMutants: 0,
688
+ killedMutants: 0,
689
+ survivedMutants: 0,
690
+ errorMutants: 0,
691
+ mutationScore: 100,
692
+ minScore,
693
+ results: [],
694
+ survivors: [],
695
+ durationMs: Date.now() - startTime,
696
+ };
697
+ }
698
+
699
+ const results = [];
700
+ let killedCount = 0;
701
+ let survivedCount = 0;
702
+ let errorCount = 0;
703
+
704
+ for (const mutant of selectedMutants) {
705
+ const res = executeMutant(mutant, {
706
+ root,
707
+ testCmd,
708
+ timeoutMs: options.timeoutMs,
709
+ executor: options.executor,
710
+ });
711
+
712
+ results.push(res);
713
+ if (res.status === "KILLED") {
714
+ killedCount++;
715
+ } else if (res.status === "SURVIVED") {
716
+ survivedCount++;
717
+ } else {
718
+ errorCount++;
719
+ }
720
+ }
721
+
722
+ const evaluatedCount = killedCount + survivedCount;
723
+ const mutationScore = evaluatedCount > 0 ? Math.round((killedCount / evaluatedCount) * 100) : 100;
724
+ const ok = mutationScore >= minScore;
725
+ const survivors = results.filter((r) => r.status === "SURVIVED");
726
+
727
+ return {
728
+ ok,
729
+ totalMutants: selectedMutants.length,
730
+ killedMutants: killedCount,
731
+ survivedMutants: survivedCount,
732
+ errorMutants: errorCount,
733
+ mutationScore,
734
+ minScore,
735
+ results,
736
+ survivors,
737
+ durationMs: Date.now() - startTime,
738
+ };
739
+ }