oxmutate 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/cli.js +18 -7
- package/dist/config.d.ts +4 -1
- package/dist/config.js +4 -1
- package/dist/coordinator.d.ts +2 -0
- package/dist/coordinator.js +34 -20
- package/dist/quiescence.js +3 -3
- package/dist/reports.js +3 -1
- package/package.json +1 -1
|
Binary file
|
package/dist/cli.js
CHANGED
|
@@ -44,8 +44,20 @@ function summaryData(r, written) {
|
|
|
44
44
|
return data;
|
|
45
45
|
}
|
|
46
46
|
const WEAKENED_SHOWN = 3;
|
|
47
|
-
function
|
|
47
|
+
function staticOverridesFrom(o) {
|
|
48
48
|
const overrides = {};
|
|
49
|
+
if (typeof o['staticGroup'] === 'number')
|
|
50
|
+
overrides.staticGroup = Math.max(1, Math.floor(o['staticGroup']));
|
|
51
|
+
if (typeof o['staticBudget'] === 'number' && o['staticBudget'] >= 1)
|
|
52
|
+
overrides.staticBudgetMs = Math.floor(o['staticBudget']);
|
|
53
|
+
if (o['reportStaticReaders'] === true)
|
|
54
|
+
overrides.staticReaders = 'report';
|
|
55
|
+
if (o['staticReaders'] === true)
|
|
56
|
+
overrides.staticReaders = 'on';
|
|
57
|
+
return overrides;
|
|
58
|
+
}
|
|
59
|
+
function overridesFrom(o, root) {
|
|
60
|
+
const overrides = { ...staticOverridesFrom(o) };
|
|
49
61
|
if (typeof o['since'] === 'string')
|
|
50
62
|
overrides.since = o['since'];
|
|
51
63
|
if (typeof o['frozenOracle'] === 'string')
|
|
@@ -60,12 +72,6 @@ function overridesFrom(o, root) {
|
|
|
60
72
|
overrides.strykerCompat = true;
|
|
61
73
|
if (o['ignoreStatic'] === true)
|
|
62
74
|
overrides.ignoreStatic = true;
|
|
63
|
-
if (typeof o['staticGroup'] === 'number')
|
|
64
|
-
overrides.staticGroup = Math.max(1, Math.floor(o['staticGroup']));
|
|
65
|
-
if (typeof o['staticBudget'] === 'number' && o['staticBudget'] >= 1)
|
|
66
|
-
overrides.staticBudgetMs = Math.floor(o['staticBudget']);
|
|
67
|
-
if (o['reportStaticReaders'] === true)
|
|
68
|
-
overrides.staticReaders = 'report';
|
|
69
75
|
if (o['incremental'] === true)
|
|
70
76
|
overrides.incremental = true;
|
|
71
77
|
if (typeof o['flakyReruns'] === 'number')
|
|
@@ -98,6 +104,7 @@ const runCommand = defineCommand({
|
|
|
98
104
|
ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
|
|
99
105
|
staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
|
|
100
106
|
staticBudget: { type: 'number', description: 'the most the static phase may take, ms; static mutants still undecided then are left out of the score' },
|
|
107
|
+
staticReaders: { type: 'boolean', description: 'run each static mutant only in the test files that read its value (a heuristic, audited)' },
|
|
101
108
|
reportStaticReaders: { type: 'boolean', description: 'one more dry run that reports how many tests static mutants would need if each ran only where its value is read; changes no verdict' },
|
|
102
109
|
incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
|
|
103
110
|
flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
|
|
@@ -132,6 +139,10 @@ const runCommand = defineCommand({
|
|
|
132
139
|
stderr.write(`oxmutate: the change weakened its tests: ${String(weakened.length)} mutant(s) the tests at ${result.frozen?.base ?? ''} detect now survive (${weakened.slice(0, WEAKENED_SHOWN).map((w) => `${w.file}:${String(w.line)} ${w.mutatorName}`).join('; ')}). fix: restore the assertions the change removed, or replace them with ones that kill these mutants\n`);
|
|
133
140
|
data['exitCode'] = ExitCode.RUNTIME;
|
|
134
141
|
}
|
|
142
|
+
if ((result.stats.staticReaders?.auditDisagreed ?? 0) > 0) {
|
|
143
|
+
stderr.write(`oxmutate: ${String(result.stats.staticReaders?.auditDisagreed ?? 0)} static mutant(s) that survived the files reading their value were killed in a file that loads it; they are reported Killed. fix: run without --static-readers; a test reads the value through a path the tracking does not see\n`);
|
|
144
|
+
data['exitCode'] = ExitCode.RUNTIME;
|
|
145
|
+
}
|
|
135
146
|
if (result.stats.staticAuditDisagreed > 0) {
|
|
136
147
|
stderr.write(`oxmutate: ${String(result.stats.staticAuditDisagreed)} mutant(s) reported as survivors by a group were killed when run alone; they are reported Killed. fix: run without static.group (--static-group 1); two of this file's mutants hide each other when armed together\n`);
|
|
137
148
|
data['exitCode'] = ExitCode.RUNTIME;
|
package/dist/config.d.ts
CHANGED
|
@@ -37,8 +37,11 @@ export interface Options {
|
|
|
37
37
|
* `static.readers: 'report'`: one more dry run, with every module-level literal reporting which
|
|
38
38
|
* tests read each of its properties, and a summary line of how many tests the static phase would
|
|
39
39
|
* run if each static mutant ran only where its value is read (roadmap 2.4). Changes no verdict.
|
|
40
|
+
* `'on'`: the same dry run, and then each static mutant runs only in the files that read its
|
|
41
|
+
* value. A heuristic: one survivor in twenty is run again in every file that loads it, and one
|
|
42
|
+
* that fails there is reported Killed and fails the run.
|
|
40
43
|
*/
|
|
41
|
-
staticReaders: 'off' | 'report';
|
|
44
|
+
staticReaders: 'off' | 'report' | 'on';
|
|
42
45
|
strykerCompat: boolean;
|
|
43
46
|
reporters: string[];
|
|
44
47
|
jsonReport: string;
|
package/dist/config.js
CHANGED
|
@@ -12,6 +12,9 @@ function staticBudgetOf(ms) {
|
|
|
12
12
|
return asked >= 1 ? asked : undefined;
|
|
13
13
|
}
|
|
14
14
|
export const STATIC_BUDGET_REASON = 'static mutant (static budget spent)';
|
|
15
|
+
function staticReadersOf(readers) {
|
|
16
|
+
return readers === 'report' || readers === 'on' ? readers : 'off';
|
|
17
|
+
}
|
|
15
18
|
export const IGNORE_STATIC_REASON = 'static mutant (ignoreStatic)';
|
|
16
19
|
const NOT_NEEDED = {
|
|
17
20
|
inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
|
|
@@ -111,7 +114,7 @@ export async function loadOptions(root, overrides = {}, configFile) {
|
|
|
111
114
|
ignoreStatic: raw['ignoreStatic'] === true,
|
|
112
115
|
staticGroup: staticGroupOf(raw['static']?.group),
|
|
113
116
|
staticBudgetMs: staticBudgetOf(raw['static']?.budgetMs),
|
|
114
|
-
staticReaders: raw['static']?.readers
|
|
117
|
+
staticReaders: staticReadersOf(raw['static']?.readers),
|
|
115
118
|
strykerCompat: raw['strykerCompat'] === true,
|
|
116
119
|
reporters: raw['reporters'] ?? ['clear-text', 'json'],
|
|
117
120
|
jsonReport: path.resolve(root, json.fileName ?? 'reports/mutation/mutation.json'),
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface RunStats {
|
|
|
87
87
|
readerTests: number;
|
|
88
88
|
fellBack: number;
|
|
89
89
|
dryRunMs: number;
|
|
90
|
+
auditSampled?: number;
|
|
91
|
+
auditDisagreed?: number;
|
|
90
92
|
} | undefined;
|
|
91
93
|
/** The first few paths those writes touched, so a suite's own scratch in a tracked folder can be told from a mutant's write. */
|
|
92
94
|
diskWritePaths: string[];
|
package/dist/coordinator.js
CHANGED
|
@@ -49,6 +49,8 @@ const verdictOptions = (o) => ({
|
|
|
49
49
|
vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
|
|
50
50
|
});
|
|
51
51
|
const UNIT_PAIRS = 400;
|
|
52
|
+
const READERS_AUDIT_EVERY = 20;
|
|
53
|
+
const auditedReader = (id) => env['OXMUTATE_STATIC_AUDIT'] === 'all' || [...id].reduce((sum, c) => sum + (c.codePointAt(0) ?? 0), 0) % READERS_AUDIT_EVERY === 0;
|
|
52
54
|
const detected = (s) => s === 'Killed' || s === 'Timeout';
|
|
53
55
|
const AUDIT_SHARE = 0.05;
|
|
54
56
|
const DEADLOCK_REASON = 'its test deadlocked: nothing left in the worker could resume it (a hang, called in under a second)';
|
|
@@ -228,9 +230,9 @@ export async function run(options) {
|
|
|
228
230
|
const dry = await dryRun();
|
|
229
231
|
stats.dryRunMs = performance.now() - d0;
|
|
230
232
|
const r0 = performance.now();
|
|
231
|
-
if (options.staticReaders
|
|
233
|
+
if (options.staticReaders !== 'off' && trackedIds.size > 0)
|
|
232
234
|
progress.phase('dry run with reads tracked (static.readers)');
|
|
233
|
-
const tracking = options.staticReaders
|
|
235
|
+
const tracking = options.staticReaders !== 'off' && trackedIds.size > 0 ? await dryRun(true) : undefined;
|
|
234
236
|
const readersDryRunMs = performance.now() - r0;
|
|
235
237
|
const failing = dry.tests.filter((t) => t.state === 'fail');
|
|
236
238
|
if (failing.length)
|
|
@@ -588,33 +590,34 @@ export async function run(options) {
|
|
|
588
590
|
const covering = byCost([...new Set(tests)]);
|
|
589
591
|
return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
|
|
590
592
|
};
|
|
593
|
+
const readerFiles = new Map();
|
|
594
|
+
const trackingTests = tracking?.tests ?? [];
|
|
595
|
+
const readsTracked = tracking !== undefined && !trackingTests.some((t) => t.state === 'fail');
|
|
596
|
+
const trackedReads = tracking?.reads ?? { static: {}, perTest: {} };
|
|
597
|
+
for (const [key, ids] of Object.entries(trackedReads.perTest)) {
|
|
598
|
+
const file = key.startsWith(FILE_KEY) ? key.slice(FILE_KEY.length) : testInfo.get(key)?.file;
|
|
599
|
+
if (!file)
|
|
600
|
+
continue;
|
|
601
|
+
for (const id of Object.keys(ids))
|
|
602
|
+
(readerFiles.get(id) ?? readerFiles.set(id, new Set()).get(id))?.add(file);
|
|
603
|
+
}
|
|
604
|
+
const readersFor = (m) => readsTracked && trackedIds.has(m.id) && !(m.id in trackedReads.static) ? filesFor(m).filter((f) => readerFiles.get(m.id)?.has(f)) : undefined;
|
|
605
|
+
const staticFilesFor = (m) => (options.staticReaders === 'on' ? readersFor(m) ?? filesFor(m) : filesFor(m));
|
|
591
606
|
const readersOf = (items) => {
|
|
592
607
|
const testsIn = new Map();
|
|
593
608
|
for (const t of dry.tests)
|
|
594
609
|
testsIn.set(t.file, (testsIn.get(t.file) ?? 0) + 1);
|
|
595
610
|
const sum = (files) => [...files].reduce((n, f) => n + (testsIn.get(f) ?? 0), 0);
|
|
596
|
-
const reads = tracking?.reads ?? { static: {}, perTest: {} };
|
|
597
|
-
const broken = tracking?.tests.some((t) => t.state === 'fail') ?? true;
|
|
598
|
-
const readerFiles = new Map();
|
|
599
|
-
for (const [key, ids] of Object.entries(reads.perTest)) {
|
|
600
|
-
const file = key.startsWith(FILE_KEY) ? key.slice(FILE_KEY.length) : testInfo.get(key)?.file;
|
|
601
|
-
if (!file)
|
|
602
|
-
continue;
|
|
603
|
-
for (const id of Object.keys(ids))
|
|
604
|
-
(readerFiles.get(id) ?? readerFiles.set(id, new Set()).get(id))?.add(file);
|
|
605
|
-
}
|
|
606
611
|
let loaderTests = 0;
|
|
607
612
|
let readerTests = 0;
|
|
608
613
|
let fellBack = 0;
|
|
609
614
|
for (const m of items) {
|
|
610
615
|
const loaders = filesFor(m);
|
|
616
|
+
const readers = readersFor(m);
|
|
611
617
|
loaderTests += sum(loaders);
|
|
612
|
-
if (
|
|
618
|
+
if (!readers)
|
|
613
619
|
fellBack++;
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
else
|
|
617
|
-
readerTests += sum(loaders.filter((f) => readerFiles.get(m.id)?.has(f)));
|
|
620
|
+
readerTests += sum(readers ?? loaders);
|
|
618
621
|
}
|
|
619
622
|
return { mutants: items.length, loaderTests, readerTests, fellBack };
|
|
620
623
|
};
|
|
@@ -632,7 +635,7 @@ export async function run(options) {
|
|
|
632
635
|
return Math.min(MAX_TEST_MS * testsRun(id), 1500 + options.timeoutMS + options.timeoutFactor * ms * 2);
|
|
633
636
|
};
|
|
634
637
|
const isolatedCapped = (id, slack) => Math.min(slack * isolatedBudget(id), MAX_TEST_MS * testsRun(id));
|
|
635
|
-
const decideStaticLive = async (items) => {
|
|
638
|
+
const decideStaticLive = async (items, filesOfMutant = staticFilesFor) => {
|
|
636
639
|
if (items.length === 0)
|
|
637
640
|
return [];
|
|
638
641
|
const STATIC_BATCH = 25;
|
|
@@ -703,7 +706,7 @@ export async function run(options) {
|
|
|
703
706
|
return unit.staticIds.length > 0;
|
|
704
707
|
};
|
|
705
708
|
const open = (m) => !killed.has(m.id) && !back.has(m.id);
|
|
706
|
-
const rounds = [(m) =>
|
|
709
|
+
const rounds = [(m) => filesOfMutant(m).slice(0, 1), (m) => (open(m) ? filesOfMutant(m).slice(1) : [])];
|
|
707
710
|
for (const [i, round] of rounds.entries())
|
|
708
711
|
await staticPool.drain(unitsFor(round, i > 0), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
|
|
709
712
|
const undecided = [];
|
|
@@ -714,7 +717,7 @@ export async function run(options) {
|
|
|
714
717
|
m.killedBy = [...new Set(k.killedBy)];
|
|
715
718
|
m.testsCompleted = ran.get(m.id) ?? 0;
|
|
716
719
|
}
|
|
717
|
-
else if (!back.has(m.id) &&
|
|
720
|
+
else if (!back.has(m.id) && filesOfMutant(m).every((f) => passed.get(m.id)?.has(f))) {
|
|
718
721
|
m.status = 'Survived';
|
|
719
722
|
m.testsCompleted = ran.get(m.id) ?? 0;
|
|
720
723
|
}
|
|
@@ -773,6 +776,17 @@ export async function run(options) {
|
|
|
773
776
|
stats.staticReaders = { ...readersOf(staticMutants), dryRunMs: readersDryRunMs };
|
|
774
777
|
const s0 = performance.now();
|
|
775
778
|
const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
|
|
779
|
+
if (options.staticReaders === 'on' && stats.staticReaders) {
|
|
780
|
+
const sampled = staticMutants.filter((m) => m.status === 'Survived' && staticFilesFor(m).length < filesFor(m).length && auditedReader(m.id));
|
|
781
|
+
for (const m of sampled)
|
|
782
|
+
m.status = 'NoCoverage';
|
|
783
|
+
await decideStaticLive(sampled, filesFor);
|
|
784
|
+
stats.staticReaders.auditSampled = sampled.length;
|
|
785
|
+
stats.staticReaders.auditDisagreed = sampled.filter((m) => m.status === 'Killed' || m.status === 'Timeout').length;
|
|
786
|
+
for (const m of sampled)
|
|
787
|
+
if (m.status === 'NoCoverage')
|
|
788
|
+
m.status = 'Survived';
|
|
789
|
+
}
|
|
776
790
|
stats.staticMs = performance.now() - s0;
|
|
777
791
|
stats.isolatedByCause.static = staticBack.length;
|
|
778
792
|
isolatedQueue.push(...staticBack);
|
package/dist/quiescence.js
CHANGED
|
@@ -126,7 +126,7 @@ export function takeFloor() {
|
|
|
126
126
|
export function confirmStep(o) {
|
|
127
127
|
if (!o.quiet)
|
|
128
128
|
return { quietSince: undefined };
|
|
129
|
-
const since = o.quietSince ?? o.now - o.span;
|
|
129
|
+
const since = o.quietSince ?? Math.max(o.now - o.span, o.start ?? -Infinity);
|
|
130
130
|
const open = o.now - since;
|
|
131
131
|
return open >= o.confirmMs ? { quietSince: since, deadlockMs: open } : { quietSince: since };
|
|
132
132
|
}
|
|
@@ -156,7 +156,7 @@ export function watch() {
|
|
|
156
156
|
};
|
|
157
157
|
if (env['OXMUTATE_DEBUG_QUIET'])
|
|
158
158
|
stderr.write(`quiet? ${JSON.stringify(reading)} res=${JSON.stringify([...resources()])} floor=${JSON.stringify([...floor])}\n`);
|
|
159
|
-
const next = confirmStep({ quietSince, quiet: quiet(reading), now, span, confirmMs: armed.confirmMs });
|
|
159
|
+
const next = confirmStep({ quietSince, quiet: quiet(reading), now, span, confirmMs: armed.confirmMs, start: armed.start });
|
|
160
160
|
quietSince = next.quietSince;
|
|
161
161
|
if (next.deadlockMs !== undefined) {
|
|
162
162
|
const { reject } = armed;
|
|
@@ -172,7 +172,7 @@ export function watch() {
|
|
|
172
172
|
return pending;
|
|
173
173
|
const deadlock = new Promise((_, reject) => {
|
|
174
174
|
quietSince = undefined;
|
|
175
|
-
armed = { reject, confirmMs: Math.max(MIN_CONFIRM_MS, confirmMs) };
|
|
175
|
+
armed = { reject, confirmMs: Math.max(MIN_CONFIRM_MS, confirmMs), start: realNow() };
|
|
176
176
|
});
|
|
177
177
|
try {
|
|
178
178
|
return await Promise.race([pending, deadlock]);
|
package/dist/reports.js
CHANGED
|
@@ -143,7 +143,9 @@ function whereItWent(r) {
|
|
|
143
143
|
const sr = s.staticReaders;
|
|
144
144
|
if (sr && sr.mutants > 0) {
|
|
145
145
|
const ratio = sr.readerTests > 0 ? `, ${(sr.loaderTests / sr.readerTests).toFixed(1)}x fewer` : '';
|
|
146
|
-
|
|
146
|
+
const inUse = sr.auditSampled !== undefined;
|
|
147
|
+
const how = inUse ? `in use, ${String(sr.auditSampled)} survivor(s) audited in every loader, ${String(sr.auditDisagreed ?? 0)} disagreed` : 'report only';
|
|
148
|
+
lines.push(` static readers: ${String(sr.mutants)} static mutants ${inUse ? 'ran' : 'would run'} ${String(sr.readerTests)} tests where their value is read, against ${String(sr.loaderTests)} in every file that loads them${ratio}; ${String(sr.fellBack)} kept every loader (${how}, ${(sr.dryRunMs / MS_PER_S).toFixed(1)} s)`);
|
|
147
149
|
}
|
|
148
150
|
const c = s.isolatedByCause;
|
|
149
151
|
const causes = [`static ${String(c.static)}`, `stopped ${String(c.stopped)}`, `unfinished ${String(c.unfinished)}`, `re-run ${String(c.rerun)}`, `survivors confirmed ${String(c.confirm)}`, `flaky ${String(c.flaky)}`];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Mutation testing for JavaScript and TypeScript on the oxc engine, run warm inside the project's own Vitest, with CI lanes, SARIF and an agent surface. No dependency outside the burgee family. A Stryker migration path.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ofri Peretz <ofriperetzdev@gmail.com>",
|