jules-orchestrator-kit 0.42.0 → 0.52.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.
- package/.agent/prompts/A11y.md +23 -0
- package/.agent/prompts/Alchemist.md +23 -0
- package/.agent/prompts/Scribe.md +23 -0
- package/.agent/prompts/Spectator.md +24 -0
- package/JULES_RULES_TEMPLATE.md +10 -8
- package/README.md +11 -3
- package/bin/agentctl.mjs +428 -1
- package/index.mjs +36 -1
- package/package.json +1 -1
- package/scripts/asset-integrity-check.mjs +1 -1
- package/scripts/rules-lint.mjs +1 -1
- package/src/assertions.mjs +244 -0
- package/src/config.mjs +25 -0
- package/src/coverage.mjs +328 -0
- package/src/engine.mjs +20 -4
- package/src/git.mjs +131 -9
- package/src/mcp.mjs +277 -0
- package/src/mutation.mjs +739 -0
- package/src/ops/checkpoint.mjs +31 -3
- package/src/perf.mjs +92 -0
- package/src/provider.mjs +144 -6
- package/src/remediation.mjs +154 -1
- package/src/rules-budget.mjs +8 -2
- package/src/scaffold.mjs +1 -1
- package/src/security.mjs +286 -28
- package/src/session-ops.mjs +312 -0
- package/src/stability.mjs +86 -0
- package/src/web-templates.mjs +172 -0
- package/src/asset_integrity.mjs +0 -1
- package/src/execution_envelope.mjs +0 -1
- package/src/rules_budget.mjs +0 -1
package/src/mutation.mjs
ADDED
|
@@ -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
|
+
}
|