oxmutate 2.3.0 → 2.4.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/binding/oxmutate.darwin-arm64.node +0 -0
- package/binding/oxmutate.darwin-x64.node +0 -0
- package/binding/oxmutate.linux-arm64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-musl.node +0 -0
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/child.js +4 -0
- package/dist/cli.js +3 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +1 -0
- package/dist/coordinator.d.ts +12 -0
- package/dist/coordinator.js +48 -4
- package/dist/native.d.ts +2 -0
- package/dist/reports.js +5 -0
- package/dist/runner.js +1 -1
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/child.js
CHANGED
|
@@ -75,6 +75,10 @@ async function runIsolated(unit) {
|
|
|
75
75
|
}
|
|
76
76
|
async function runUnit(unit) {
|
|
77
77
|
env['OXMUTATE_MODE'] = unit.mode;
|
|
78
|
+
if (unit.trackReads)
|
|
79
|
+
env['__OXMUTATE_TRACK_READS__'] = '1';
|
|
80
|
+
else
|
|
81
|
+
delete env['__OXMUTATE_TRACK_READS__'];
|
|
78
82
|
env['OXMUTATE_RESULTS'] = unit.results;
|
|
79
83
|
env['OXMUTATE_PROGRESS'] = unit.progress;
|
|
80
84
|
if (unit.plan)
|
package/dist/cli.js
CHANGED
|
@@ -64,6 +64,8 @@ function overridesFrom(o, root) {
|
|
|
64
64
|
overrides.staticGroup = Math.max(1, Math.floor(o['staticGroup']));
|
|
65
65
|
if (typeof o['staticBudget'] === 'number' && o['staticBudget'] >= 1)
|
|
66
66
|
overrides.staticBudgetMs = Math.floor(o['staticBudget']);
|
|
67
|
+
if (o['reportStaticReaders'] === true)
|
|
68
|
+
overrides.staticReaders = 'report';
|
|
67
69
|
if (o['incremental'] === true)
|
|
68
70
|
overrides.incremental = true;
|
|
69
71
|
if (typeof o['flakyReruns'] === 'number')
|
|
@@ -96,6 +98,7 @@ const runCommand = defineCommand({
|
|
|
96
98
|
ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
|
|
97
99
|
staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
|
|
98
100
|
staticBudget: { type: 'number', description: 'the most the static phase may take, ms; static mutants still undecided then are left out of the score' },
|
|
101
|
+
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' },
|
|
99
102
|
incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
|
|
100
103
|
flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
|
|
101
104
|
reporter: { type: 'string', description: 'comma-separated: clear-text, json, sarif, html (default clear-text,json)' },
|
package/dist/config.d.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface Options {
|
|
|
33
33
|
* left out of the score. A kill found before that stands. Unset: no limit.
|
|
34
34
|
*/
|
|
35
35
|
staticBudgetMs?: number | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* `static.readers: 'report'`: one more dry run, with every module-level literal reporting which
|
|
38
|
+
* tests read each of its properties, and a summary line of how many tests the static phase would
|
|
39
|
+
* run if each static mutant ran only where its value is read (roadmap 2.4). Changes no verdict.
|
|
40
|
+
*/
|
|
41
|
+
staticReaders: 'off' | 'report';
|
|
36
42
|
strykerCompat: boolean;
|
|
37
43
|
reporters: string[];
|
|
38
44
|
jsonReport: string;
|
package/dist/config.js
CHANGED
|
@@ -111,6 +111,7 @@ export async function loadOptions(root, overrides = {}, configFile) {
|
|
|
111
111
|
ignoreStatic: raw['ignoreStatic'] === true,
|
|
112
112
|
staticGroup: staticGroupOf(raw['static']?.group),
|
|
113
113
|
staticBudgetMs: staticBudgetOf(raw['static']?.budgetMs),
|
|
114
|
+
staticReaders: raw['static']?.readers === 'report' ? 'report' : 'off',
|
|
114
115
|
strykerCompat: raw['strykerCompat'] === true,
|
|
115
116
|
reporters: raw['reporters'] ?? ['clear-text', 'json'],
|
|
116
117
|
jsonReport: path.resolve(root, json.fileName ?? 'reports/mutation/mutation.json'),
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -76,6 +76,18 @@ export interface RunStats {
|
|
|
76
76
|
pollutionRestarts: number;
|
|
77
77
|
/** Units after which the project on disk had changed (diskguard.ts): put back, and their verdicts and those of the units running beside them re-run. */
|
|
78
78
|
diskWrites: number;
|
|
79
|
+
/**
|
|
80
|
+
* `static.readers: 'report'` (roadmap 2.4): over the static mutants, the tests of every file that
|
|
81
|
+
* loads each one's module (what the static phase runs), and the tests of only the files that read
|
|
82
|
+
* its value. A mutant whose value is untracked, or read while a module loaded, counts its loaders.
|
|
83
|
+
*/
|
|
84
|
+
staticReaders?: {
|
|
85
|
+
mutants: number;
|
|
86
|
+
loaderTests: number;
|
|
87
|
+
readerTests: number;
|
|
88
|
+
fellBack: number;
|
|
89
|
+
dryRunMs: number;
|
|
90
|
+
} | undefined;
|
|
79
91
|
/** 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. */
|
|
80
92
|
diskWritePaths: string[];
|
|
81
93
|
/** Survivors of the warm run that a whole-file re-run killed (cross-test effects). */
|
package/dist/coordinator.js
CHANGED
|
@@ -121,6 +121,7 @@ export async function run(options) {
|
|
|
121
121
|
throw new InstrumentFault(`cannot parse ${String(parseErrors.length)} file(s): ${parseErrors.slice(0, 3).join('; ')}`);
|
|
122
122
|
const changed = options.since ? await changedLines(root, options.since) : undefined;
|
|
123
123
|
const mutants = [];
|
|
124
|
+
const trackedIds = new Set();
|
|
124
125
|
const codeMap = {};
|
|
125
126
|
const directiveErrors = [];
|
|
126
127
|
for (const f of instrumented) {
|
|
@@ -139,8 +140,11 @@ export async function run(options) {
|
|
|
139
140
|
const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
|
|
140
141
|
const inRange = ranged.reasonFor(m.start.line, m.mutatorName);
|
|
141
142
|
const excluded = options.excludedMutations.includes(m.mutatorName);
|
|
143
|
+
if (m.tracked)
|
|
144
|
+
trackedIds.add(m.id);
|
|
145
|
+
const { tracked: _tracked, ...plain } = m;
|
|
142
146
|
mutants.push({
|
|
143
|
-
...
|
|
147
|
+
...plain,
|
|
144
148
|
file: f.path,
|
|
145
149
|
coveredBy: [],
|
|
146
150
|
status: off || inRange !== undefined || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
|
|
@@ -184,8 +188,8 @@ export async function run(options) {
|
|
|
184
188
|
env[SPEC_TRACES_ENV] = JSON.stringify(traceDirs);
|
|
185
189
|
const dryPool = createPool({ root, vitestConfig: options.vitestConfig, workers: options.concurrency, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs }, 1, work, 'dry');
|
|
186
190
|
pools.push(dryPool);
|
|
187
|
-
const dryRun = async () => {
|
|
188
|
-
const unit = { mode: 'dry', files: [], results: fileFor('dry', 'jsonl'), progress: fileFor('dry', 'progress') };
|
|
191
|
+
const dryRun = async (trackReads = false) => {
|
|
192
|
+
const unit = { mode: 'dry', files: [], results: fileFor('dry', 'jsonl'), progress: fileFor('dry', 'progress'), ...(trackReads && { trackReads: true }) };
|
|
189
193
|
let failure;
|
|
190
194
|
let overBudget = false;
|
|
191
195
|
await dryPool.drain([unit], () => options.dryRunTimeoutMinutes * MS_PER_MINUTE, (_, o) => {
|
|
@@ -202,10 +206,13 @@ export async function run(options) {
|
|
|
202
206
|
throw new InstrumentFault(`the dry run did not complete: ${failure.stderr.slice(-1500)}`);
|
|
203
207
|
const perTest = {};
|
|
204
208
|
const stat = {};
|
|
209
|
+
const reads = { static: {}, perTest: {} };
|
|
205
210
|
for (const r of recs) {
|
|
206
211
|
Object.assign(perTest, r.coverage?.perTest ?? {});
|
|
207
212
|
for (const [k, v] of Object.entries(r.coverage?.static ?? {}))
|
|
208
213
|
stat[k] = (stat[k] ?? 0) + v;
|
|
214
|
+
Object.assign(reads.perTest, r.reads?.perTest ?? {});
|
|
215
|
+
Object.assign(reads.static, r.reads?.static ?? {});
|
|
209
216
|
}
|
|
210
217
|
const fileErrors = recs.flatMap((r) => r.fileErrors ?? []);
|
|
211
218
|
if (fileErrors.length) {
|
|
@@ -214,12 +221,17 @@ export async function run(options) {
|
|
|
214
221
|
const unhandled = recs.reduce((n, r) => n + (r.unhandled ?? 0), 0);
|
|
215
222
|
if (unhandled > 0)
|
|
216
223
|
throw new InstrumentFault(`the suite raised ${String(unhandled)} unhandled error(s) with no mutant active, so no verdict can be trusted. fix: make \`vitest run\` pass with no "Unhandled Errors" first`);
|
|
217
|
-
return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})) };
|
|
224
|
+
return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})), reads };
|
|
218
225
|
};
|
|
219
226
|
progress.phase('dry run');
|
|
220
227
|
const d0 = performance.now();
|
|
221
228
|
const dry = await dryRun();
|
|
222
229
|
stats.dryRunMs = performance.now() - d0;
|
|
230
|
+
const r0 = performance.now();
|
|
231
|
+
if (options.staticReaders === 'report' && trackedIds.size > 0)
|
|
232
|
+
progress.phase('dry run with reads tracked (static.readers)');
|
|
233
|
+
const tracking = options.staticReaders === 'report' && trackedIds.size > 0 ? await dryRun(true) : undefined;
|
|
234
|
+
const readersDryRunMs = performance.now() - r0;
|
|
223
235
|
const failing = dry.tests.filter((t) => t.state === 'fail');
|
|
224
236
|
if (failing.length)
|
|
225
237
|
throw new InstrumentFault(`${String(failing.length)} test(s) fail with no mutant active, so no verdict can be trusted: ${failing.slice(0, 5).map((t) => t.name).join('; ')}. fix: make the suite pass first`);
|
|
@@ -576,6 +588,36 @@ export async function run(options) {
|
|
|
576
588
|
const covering = byCost([...new Set(tests)]);
|
|
577
589
|
return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
|
|
578
590
|
};
|
|
591
|
+
const readersOf = (items) => {
|
|
592
|
+
const testsIn = new Map();
|
|
593
|
+
for (const t of dry.tests)
|
|
594
|
+
testsIn.set(t.file, (testsIn.get(t.file) ?? 0) + 1);
|
|
595
|
+
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
|
+
let loaderTests = 0;
|
|
607
|
+
let readerTests = 0;
|
|
608
|
+
let fellBack = 0;
|
|
609
|
+
for (const m of items) {
|
|
610
|
+
const loaders = filesFor(m);
|
|
611
|
+
loaderTests += sum(loaders);
|
|
612
|
+
if (broken || !trackedIds.has(m.id) || m.id in reads.static) {
|
|
613
|
+
fellBack++;
|
|
614
|
+
readerTests += sum(loaders);
|
|
615
|
+
}
|
|
616
|
+
else
|
|
617
|
+
readerTests += sum(loaders.filter((f) => readerFiles.get(m.id)?.has(f)));
|
|
618
|
+
}
|
|
619
|
+
return { mutants: items.length, loaderTests, readerTests, fellBack };
|
|
620
|
+
};
|
|
579
621
|
const testsRun = (id) => {
|
|
580
622
|
const m = byId.get(id);
|
|
581
623
|
const fs = m ? filesFor(m) : allTestFiles;
|
|
@@ -727,6 +769,8 @@ export async function run(options) {
|
|
|
727
769
|
verdicts.set(m.id, { status: 'RuntimeError' });
|
|
728
770
|
return verdicts;
|
|
729
771
|
};
|
|
772
|
+
if (tracking)
|
|
773
|
+
stats.staticReaders = { ...readersOf(staticMutants), dryRunMs: readersDryRunMs };
|
|
730
774
|
const s0 = performance.now();
|
|
731
775
|
const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
|
|
732
776
|
stats.staticMs = performance.now() - s0;
|
package/dist/native.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface NativeMutant {
|
|
|
10
10
|
end: Location;
|
|
11
11
|
/** Set when an ignore rule covers the mutant: reported as Ignored, never placed in the code. */
|
|
12
12
|
ignoredReason?: string;
|
|
13
|
+
/** Reads of the property holding it are reported by a tracking dry run (`oxmTrack`, roadmap 2.4). */
|
|
14
|
+
tracked?: boolean;
|
|
13
15
|
}
|
|
14
16
|
export interface InstrumentedFile {
|
|
15
17
|
path: string;
|
package/dist/reports.js
CHANGED
|
@@ -140,6 +140,11 @@ function whereItWent(r) {
|
|
|
140
140
|
if (s.warmUnitMs > 0)
|
|
141
141
|
lines.push(` warm servers ran ${(s.warmUnitMs / MS_PER_S).toFixed(1)} s, ${(s.warmArmedMs / MS_PER_S).toFixed(1)} s of it inside test runs`);
|
|
142
142
|
lines.push(...staticLine(r));
|
|
143
|
+
const sr = s.staticReaders;
|
|
144
|
+
if (sr && sr.mutants > 0) {
|
|
145
|
+
const ratio = sr.readerTests > 0 ? `, ${(sr.loaderTests / sr.readerTests).toFixed(1)}x fewer` : '';
|
|
146
|
+
lines.push(` static readers: ${String(sr.mutants)} static mutants 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 (report only, ${(sr.dryRunMs / MS_PER_S).toFixed(1)} s)`);
|
|
147
|
+
}
|
|
143
148
|
const c = s.isolatedByCause;
|
|
144
149
|
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)}`];
|
|
145
150
|
if (s.isolatedRuns > 0)
|
package/dist/runner.js
CHANGED
|
@@ -655,7 +655,7 @@ export default class OxmutateRunner extends Base {
|
|
|
655
655
|
if (result?.state === 'fail' && !f.tasks?.length)
|
|
656
656
|
fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
|
|
657
657
|
}
|
|
658
|
-
emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules), unhandled });
|
|
658
|
+
emit({ type: 'dry', tests, coverage: ns.mutantCoverage, reads: ns.readCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules), unhandled });
|
|
659
659
|
}
|
|
660
660
|
else if (mode === 'isolated') {
|
|
661
661
|
const failed = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
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>",
|