@mizchi/mutator-jest 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mizchi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,24 @@
1
+ import type { InstrumentOptions } from '@mizchi/mutator-core';
2
+ export declare const SETUP_FILE: string;
3
+ export interface JestProject {
4
+ /** `jest/bin/jest.js` resolved from the project root. */
5
+ bin: string;
6
+ /** The project's config file, if any. */
7
+ configPath: string | undefined;
8
+ /** Raw (un-normalized) options as written by the user, with an absolute rootDir. */
9
+ raw: Record<string, unknown>;
10
+ /** Normalized transform entries: [pattern, absolute transformer path, options]. */
11
+ transform: [string, string, unknown][];
12
+ /** Absolute setupFiles / setupFilesAfterEnv / globalSetup / globalTeardown of the project. */
13
+ setupFiles: string[];
14
+ }
15
+ export declare function readJestProject(root: string, configFile?: string): Promise<JestProject>;
16
+ export interface TransformerConfig {
17
+ inner: [string, unknown] | null;
18
+ root: string;
19
+ targets: string[];
20
+ pluginModules: string[];
21
+ arid?: InstrumentOptions['arid'];
22
+ excludedMutators?: readonly string[];
23
+ }
24
+ export declare function mutatorJestConfig(project: JestProject, transformer: Omit<TransformerConfig, 'inner'>, cacheDirectory: string): Record<string, unknown>;
package/dist/config.js ADDED
@@ -0,0 +1,54 @@
1
+ // Reads the project's Jest config with its own jest-config and derives the config
2
+ // the mutator runs Jest with: every transform wrapped by our transformer, our
3
+ // runtime setup in front of setupFiles / setupFilesAfterEnv.
4
+ import { createRequire } from 'node:module';
5
+ import { dirname, isAbsolute, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ // Published builds run from dist/; the CommonJS helpers ship in src/.
8
+ const own = (name) => fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? `./${name}` : `../src/${name}`, import.meta.url));
9
+ const TRANSFORMER = own('transformer.cjs');
10
+ export const SETUP_FILE = own('setup.cjs');
11
+ const HOOKS_FILE = own('hooks.cjs');
12
+ /** Jest's own default pattern for JavaScript / TypeScript. */
13
+ const SOURCE_PATTERN = '\\.[cm]?[jt]sx?$';
14
+ export async function readJestProject(root, configFile) {
15
+ let jestPackage;
16
+ try {
17
+ jestPackage = createRequire(join(root, 'package.json')).resolve('jest/package.json');
18
+ }
19
+ catch (error) {
20
+ throw new Error(`mutator: cannot resolve jest from ${root}`, { cause: error });
21
+ }
22
+ const fromJest = createRequire(jestPackage);
23
+ const jestConfig = createRequire(fromJest.resolve('jest-cli/package.json'))('jest-config');
24
+ const { config: raw, configPath } = await jestConfig.readInitialOptions(configFile, { packageRootOrConfig: configFile ?? root });
25
+ if (raw.projects)
26
+ throw new Error('mutator: Jest `projects` are not supported');
27
+ const { options } = await jestConfig.normalize(raw, {});
28
+ const setupFiles = ['setupFiles', 'setupFilesAfterEnv', 'globalSetup', 'globalTeardown'].flatMap((key) => [options[key] ?? []].flat());
29
+ return {
30
+ bin: join(dirname(jestPackage), 'bin', 'jest.js'),
31
+ configPath: configPath && isAbsolute(configPath) && !configPath.endsWith('package.json') ? configPath : undefined,
32
+ raw,
33
+ transform: options.transform ?? [],
34
+ setupFiles: setupFiles.filter((f) => typeof f === 'string' && isAbsolute(f)),
35
+ };
36
+ }
37
+ export function mutatorJestConfig(project, transformer, cacheDirectory) {
38
+ const transform = {};
39
+ const entries = project.transform.length > 0 ? project.transform : [[SOURCE_PATTERN, null, {}]];
40
+ for (const [pattern, path, options] of entries) {
41
+ transform[pattern] = [TRANSFORMER, { ...transformer, inner: path ? [path, options] : null }];
42
+ }
43
+ const list = (key) => [project.raw[key] ?? []].flat();
44
+ return {
45
+ ...project.raw,
46
+ transform,
47
+ setupFiles: [SETUP_FILE, ...list('setupFiles')],
48
+ setupFilesAfterEnv: [HOOKS_FILE, ...list('setupFilesAfterEnv')],
49
+ cacheDirectory,
50
+ watchman: false,
51
+ collectCoverage: false,
52
+ };
53
+ }
54
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export { type JestSessionOptions, createJestSession } from './session.ts';
2
+ export type { DryRunResult, MutantRunResult, RunMutantOptions, Session, TestLocation } from '@mizchi/mutator-core';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createJestSession } from './session.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2B,iBAAiB,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,16 @@
1
+ import { type InstrumentOptions, type Session } from '@mizchi/mutator-core';
2
+ export interface JestSessionOptions {
3
+ root: string;
4
+ /** Absolute paths of the source files to mutate. */
5
+ targets: readonly string[];
6
+ /** Jest config file; defaults to Jest's own lookup from `root`. */
7
+ configFile?: string;
8
+ arid?: InstrumentOptions['arid'];
9
+ excludedMutators?: InstrumentOptions['excludedMutators'];
10
+ /** Custom mutators / ignorers, used for `mutants()` in this process. */
11
+ mutators?: InstrumentOptions['mutators'];
12
+ ignorers?: InstrumentOptions['ignorers'];
13
+ /** Absolute paths of the plugin modules providing `mutators` / `ignorers`; Jest's transformer loads them itself. */
14
+ pluginModules?: readonly string[];
15
+ }
16
+ export declare function createJestSession(options: JestSessionOptions): Promise<Session>;
@@ -0,0 +1,267 @@
1
+ // Jest session: every dry run and every mutant run is a separate Jest child
2
+ // process (native ESM via --experimental-vm-modules), so a mutant stuck in a
3
+ // synchronous loop is killed with its process group instead of freezing the CLI.
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, relative } from 'node:path';
8
+ import { DEFAULT_HIT_LIMIT, HIT_LIMIT_MESSAGE, emptyDryRun, hash, instrument, numberDuplicates, } from '@mizchi/mutator-core';
9
+ import { mutatorJestConfig, readJestProject } from './config.js';
10
+ /** Jest start-up allowance added to every mutant timeout until a run has been measured. */
11
+ const DEFAULT_STARTUP_MS = 3000;
12
+ export async function createJestSession(options) {
13
+ const { root } = options;
14
+ if ((options.mutators?.length || options.ignorers?.length) && !options.pluginModules?.length) {
15
+ throw new Error('mutator: custom mutators / ignorers reach Jest only as plugin modules (pluginModules)');
16
+ }
17
+ const project = await readJestProject(root, options.configFile);
18
+ const dir = mkdtempSync(join(tmpdir(), 'mutator-jest-'));
19
+ const configPath = join(dir, 'jest.config.json');
20
+ const transformer = {
21
+ root,
22
+ targets: [...options.targets],
23
+ pluginModules: [...(options.pluginModules ?? [])],
24
+ ...(options.arid !== undefined ? { arid: options.arid } : {}),
25
+ ...(options.excludedMutators ? { excludedMutators: options.excludedMutators } : {}),
26
+ };
27
+ writeFileSync(configPath, JSON.stringify(mutatorJestConfig(project, transformer, join(dir, 'cache'))));
28
+ const locations = new Map();
29
+ let mutants;
30
+ let startupMs = DEFAULT_STARTUP_MS;
31
+ let runs = 0;
32
+ const jest = async (args, env, timeoutMs = Infinity) => {
33
+ const outputFile = join(dir, `report-${runs++}.json`);
34
+ const argv = [
35
+ '--experimental-vm-modules',
36
+ '--disable-warning=ExperimentalWarning',
37
+ project.bin,
38
+ '--config',
39
+ configPath,
40
+ '--ci',
41
+ '--silent',
42
+ '--json',
43
+ '--outputFile',
44
+ outputFile,
45
+ '--passWithNoTests',
46
+ '--forceExit',
47
+ ...args,
48
+ ];
49
+ const started = performance.now();
50
+ const child = spawn(process.execPath, argv, {
51
+ cwd: root,
52
+ detached: true,
53
+ stdio: ['ignore', 'ignore', 'pipe'],
54
+ env: { ...process.env, ...env },
55
+ });
56
+ let stderr = '';
57
+ child.stderr.on('data', (chunk) => {
58
+ if (stderr.length < 64 * 1024)
59
+ stderr += chunk.toString();
60
+ });
61
+ let timedOut = false;
62
+ const timer = Number.isFinite(timeoutMs)
63
+ ? setTimeout(() => {
64
+ timedOut = true;
65
+ killGroup(child.pid);
66
+ }, timeoutMs)
67
+ : undefined;
68
+ await new Promise((resolve) => child.on('close', () => resolve()));
69
+ clearTimeout(timer);
70
+ // Workers Jest forked are in the same group; make sure none outlives the run.
71
+ killGroup(child.pid);
72
+ let report;
73
+ try {
74
+ report = JSON.parse(readFileSync(outputFile, 'utf8'));
75
+ }
76
+ catch {
77
+ report = undefined;
78
+ }
79
+ rmSync(outputFile, { force: true });
80
+ return { report, timedOut, stderr, wallMs: performance.now() - started };
81
+ };
82
+ const measure = (outcome) => {
83
+ if (!outcome.report)
84
+ return;
85
+ const testsMs = outcome.report.testResults.flatMap((s) => s.assertionResults).reduce((sum, a) => sum + (a.duration ?? 0), 0);
86
+ startupMs = Math.max(1000, outcome.wallMs - testsMs);
87
+ };
88
+ return {
89
+ mutants() {
90
+ mutants ??= options.targets.flatMap((file) => instrument(file, readFileSync(file, 'utf8'), {
91
+ identity: relative(root, file),
92
+ ...(options.arid !== undefined ? { arid: options.arid } : {}),
93
+ ...(options.excludedMutators ? { excludedMutators: options.excludedMutators } : {}),
94
+ ...(options.mutators ? { mutators: options.mutators } : {}),
95
+ ...(options.ignorers ? { ignorers: options.ignorers } : {}),
96
+ }).mutants);
97
+ return mutants;
98
+ },
99
+ configFiles() {
100
+ return [...new Set([...(project.configPath ? [project.configPath] : []), ...project.setupFiles])].sort();
101
+ },
102
+ async testFiles() {
103
+ return listTests(project, configPath, root);
104
+ },
105
+ async dryRun(files) {
106
+ if (files && files.length === 0)
107
+ return emptyDryRun();
108
+ const coverageDir = join(dir, `coverage-${runs}`);
109
+ mkdirSync(coverageDir, { recursive: true });
110
+ const outcome = await jest(files ? ['--runTestsByPath', ...files] : [], {
111
+ MUTATOR_COVERAGE_DIR: coverageDir,
112
+ MUTATOR_HIT_LIMIT: String(DEFAULT_HIT_LIMIT),
113
+ });
114
+ if (!outcome.report)
115
+ throw new Error(`mutator: jest produced no report\n${outcome.stderr}`);
116
+ measure(outcome);
117
+ const coverage = new Map();
118
+ for (const name of readdirSync(coverageDir)) {
119
+ const file = JSON.parse(readFileSync(join(coverageDir, name), 'utf8'));
120
+ coverage.set(file.testPath, file);
121
+ }
122
+ rmSync(coverageDir, { recursive: true, force: true });
123
+ const dry = collectDryRun(outcome.report, coverage, root);
124
+ for (const [id, location] of dry.index)
125
+ locations.set(id, location);
126
+ return dry;
127
+ },
128
+ useTestIndex(index) {
129
+ locations.clear();
130
+ for (const [id, location] of index)
131
+ locations.set(id, location);
132
+ },
133
+ async runMutant(key, testIds, { timeoutMs, hitLimit = DEFAULT_HIT_LIMIT, earlyExit = true }) {
134
+ const byFile = new Map();
135
+ for (const id of testIds) {
136
+ const location = locations.get(id);
137
+ if (!location)
138
+ continue;
139
+ const names = byFile.get(location.moduleId) ?? new Set();
140
+ names.add(location.taskId);
141
+ byFile.set(location.moduleId, names);
142
+ }
143
+ if (byFile.size === 0)
144
+ return { status: 'Survived', killedBy: [], durationMs: 0 };
145
+ const names = [...new Set([...byFile.values()].flatMap((s) => [...s]))];
146
+ const pattern = `^(?:${names.map(escapeRegExp).join('|')})$`;
147
+ const outcome = await jest(
148
+ // --bail stops after the first failing test file; without it every file runs (--kill-matrix).
149
+ ['--runInBand', ...(earlyExit ? ['--bail'] : []), '--testNamePattern', pattern, '--runTestsByPath', ...byFile.keys()], { MUTATOR_ACTIVE: key, MUTATOR_HIT_LIMIT: String(hitLimit) }, timeoutMs + startupMs);
150
+ const durationMs = outcome.wallMs;
151
+ if (outcome.timedOut)
152
+ return { status: 'Timeout', killedBy: [], durationMs };
153
+ if (!outcome.report)
154
+ return { status: 'RuntimeError', killedBy: [], durationMs };
155
+ measure(outcome);
156
+ return { ...classify(outcome.report, root, new Set(testIds)), durationMs };
157
+ },
158
+ async close() {
159
+ rmSync(dir, { recursive: true, force: true });
160
+ },
161
+ };
162
+ }
163
+ function killGroup(pid) {
164
+ if (pid === undefined)
165
+ return;
166
+ try {
167
+ process.kill(-pid, 'SIGKILL');
168
+ }
169
+ catch {
170
+ // already gone
171
+ }
172
+ }
173
+ async function listTests(project, configPath, root) {
174
+ const child = spawn(process.execPath, ['--experimental-vm-modules', '--disable-warning=ExperimentalWarning', project.bin, '--config', configPath, '--listTests', '--json'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
175
+ let stdout = '';
176
+ let stderr = '';
177
+ child.stdout.on('data', (c) => (stdout += c.toString()));
178
+ child.stderr.on('data', (c) => (stderr += c.toString()));
179
+ const code = await new Promise((resolve) => child.on('close', resolve));
180
+ const json = stdout.slice(stdout.indexOf('['));
181
+ try {
182
+ return JSON.parse(json).sort();
183
+ }
184
+ catch {
185
+ throw new Error(`mutator: jest --listTests failed (exit ${code})\n${stderr}`);
186
+ }
187
+ }
188
+ function escapeRegExp(s) {
189
+ return s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
190
+ }
191
+ const SKIPPED = new Set(['pending', 'skipped', 'todo', 'disabled', 'focused']);
192
+ /** Test ids with `#n` suffixes for duplicate full names, as the vitest session does. */
193
+ function* identify(suite, root) {
194
+ const rel = relative(root, suite.name);
195
+ const distinct = numberDuplicates();
196
+ const executed = numberDuplicates();
197
+ for (const assertion of suite.assertionResults) {
198
+ const id = distinct(`${rel}#${assertion.fullName}`);
199
+ // The setup hooks only see executed tests; their counter skips skipped ones.
200
+ const coverageKey = SKIPPED.has(assertion.status) ? assertion.fullName : executed(assertion.fullName);
201
+ yield { id, assertion, coverageKey };
202
+ }
203
+ }
204
+ function collectDryRun(report, coverageByFile, root) {
205
+ const dry = emptyDryRun();
206
+ for (const suite of report.testResults) {
207
+ const rel = relative(root, suite.name);
208
+ const cov = coverageByFile.get(suite.name);
209
+ const fileStatic = new Set();
210
+ dry.staticByFile.set(rel, fileStatic);
211
+ for (const key of Object.keys(cov?.static ?? {})) {
212
+ dry.staticKeys.add(key);
213
+ fileStatic.add(key);
214
+ }
215
+ for (const [key, n] of Object.entries(cov?.counts ?? {}))
216
+ dry.hits.set(key, (dry.hits.get(key) ?? 0) + n);
217
+ if (suite.status === 'failed' && !suite.assertionResults.some((a) => a.status === 'failed'))
218
+ dry.failed.push(rel);
219
+ const fingerprint = hash(readFileSync(suite.name, 'utf8'));
220
+ for (const { id, assertion, coverageKey } of identify(suite, root)) {
221
+ dry.index.set(id, { moduleId: suite.name, taskId: assertion.fullName });
222
+ if (SKIPPED.has(assertion.status))
223
+ continue;
224
+ if (assertion.status === 'failed')
225
+ dry.failed.push(id);
226
+ const test = { id, fingerprint: hash(`${fingerprint}\0${id}`), durationMs: assertion.duration ?? 0 };
227
+ dry.tests.push(test);
228
+ const spawned = cov?.spawns?.[coverageKey];
229
+ if (spawned)
230
+ (dry.spawns ??= new Map()).set(id, spawned);
231
+ for (const key of Object.keys(cov?.perTest[coverageKey] ?? {})) {
232
+ const covering = dry.coverage.get(key) ?? [];
233
+ covering.push(id);
234
+ dry.coverage.set(key, covering);
235
+ }
236
+ }
237
+ }
238
+ return dry;
239
+ }
240
+ function classify(report, root, selected) {
241
+ const killedBy = [];
242
+ const messages = [];
243
+ let suiteError = false;
244
+ for (const suite of report.testResults) {
245
+ const failedTests = suite.assertionResults.some((a) => a.status === 'failed');
246
+ if (suite.status === 'failed' && !failedTests) {
247
+ suiteError = true;
248
+ killedBy.push(relative(root, suite.name));
249
+ messages.push(suite.message ?? '');
250
+ }
251
+ for (const { id, assertion } of identify(suite, root)) {
252
+ if (assertion.status !== 'failed' || !selected.has(id))
253
+ continue;
254
+ killedBy.push(id);
255
+ messages.push((assertion.failureMessages ?? []).join('\n'));
256
+ }
257
+ }
258
+ if (killedBy.length === 0)
259
+ return { status: 'Survived', killedBy };
260
+ // The hit limit guards against endless loops: a run stopped only by it is a timeout.
261
+ if (messages.every((m) => m.includes(HIT_LIMIT_MESSAGE)))
262
+ return { status: 'Timeout', killedBy: [] };
263
+ if (suiteError && killedBy.every((k) => !k.includes('#')))
264
+ return { status: 'RuntimeError', killedBy };
265
+ return { status: 'Killed', killedBy };
266
+ }
267
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@mizchi/mutator-jest",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Jest adapter for @mizchi/mutator-core (experimental)",
6
+ "keywords": [
7
+ "mutation-testing",
8
+ "jest"
9
+ ],
10
+ "license": "MIT",
11
+ "author": "mizchi",
12
+ "homepage": "https://github.com/mizchi/mutator#readme",
13
+ "bugs": "https://github.com/mizchi/mutator/issues",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/mizchi/mutator.git",
17
+ "directory": "packages/jest"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "peerDependencies": {
33
+ "jest": ">=30.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "jest": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "dependencies": {
41
+ "@mizchi/mutator-core": "0.1.0"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "devDependencies": {
47
+ "jest": "30.5.2"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc -p tsconfig.build.json"
51
+ }
6
52
  }
package/src/config.ts ADDED
@@ -0,0 +1,83 @@
1
+ // Reads the project's Jest config with its own jest-config and derives the config
2
+ // the mutator runs Jest with: every transform wrapped by our transformer, our
3
+ // runtime setup in front of setupFiles / setupFilesAfterEnv.
4
+ import { createRequire } from 'node:module';
5
+ import { dirname, isAbsolute, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import type { InstrumentOptions } from '@mizchi/mutator-core';
8
+
9
+ // Published builds run from dist/; the CommonJS helpers ship in src/.
10
+ const own = (name: string) => fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? `./${name}` : `../src/${name}`, import.meta.url));
11
+ const TRANSFORMER = own('transformer.cjs');
12
+ export const SETUP_FILE = own('setup.cjs');
13
+ const HOOKS_FILE = own('hooks.cjs');
14
+
15
+ /** Jest's own default pattern for JavaScript / TypeScript. */
16
+ const SOURCE_PATTERN = '\\.[cm]?[jt]sx?$';
17
+
18
+ export interface JestProject {
19
+ /** `jest/bin/jest.js` resolved from the project root. */
20
+ bin: string;
21
+ /** The project's config file, if any. */
22
+ configPath: string | undefined;
23
+ /** Raw (un-normalized) options as written by the user, with an absolute rootDir. */
24
+ raw: Record<string, unknown>;
25
+ /** Normalized transform entries: [pattern, absolute transformer path, options]. */
26
+ transform: [string, string, unknown][];
27
+ /** Absolute setupFiles / setupFilesAfterEnv / globalSetup / globalTeardown of the project. */
28
+ setupFiles: string[];
29
+ }
30
+
31
+ interface JestConfigModule {
32
+ readInitialOptions(config: string | undefined, options: { packageRootOrConfig: string }): Promise<{ config: Record<string, unknown>; configPath: string | null }>;
33
+ normalize(options: Record<string, unknown>, argv: Record<string, unknown>): Promise<{ options: Record<string, unknown> }>;
34
+ }
35
+
36
+ export async function readJestProject(root: string, configFile?: string): Promise<JestProject> {
37
+ let jestPackage: string;
38
+ try {
39
+ jestPackage = createRequire(join(root, 'package.json')).resolve('jest/package.json');
40
+ } catch (error) {
41
+ throw new Error(`mutator: cannot resolve jest from ${root}`, { cause: error });
42
+ }
43
+ const fromJest = createRequire(jestPackage);
44
+ const jestConfig = createRequire(fromJest.resolve('jest-cli/package.json'))('jest-config') as JestConfigModule;
45
+ const { config: raw, configPath } = await jestConfig.readInitialOptions(configFile, { packageRootOrConfig: configFile ?? root });
46
+ if (raw.projects) throw new Error('mutator: Jest `projects` are not supported');
47
+ const { options } = await jestConfig.normalize(raw, {});
48
+ const setupFiles = ['setupFiles', 'setupFilesAfterEnv', 'globalSetup', 'globalTeardown'].flatMap((key) => [options[key] ?? []].flat() as string[]);
49
+ return {
50
+ bin: join(dirname(jestPackage), 'bin', 'jest.js'),
51
+ configPath: configPath && isAbsolute(configPath) && !configPath.endsWith('package.json') ? configPath : undefined,
52
+ raw,
53
+ transform: (options.transform as [string, string, unknown][] | undefined) ?? [],
54
+ setupFiles: setupFiles.filter((f) => typeof f === 'string' && isAbsolute(f)),
55
+ };
56
+ }
57
+
58
+ export interface TransformerConfig {
59
+ inner: [string, unknown] | null;
60
+ root: string;
61
+ targets: string[];
62
+ pluginModules: string[];
63
+ arid?: InstrumentOptions['arid'];
64
+ excludedMutators?: readonly string[];
65
+ }
66
+
67
+ export function mutatorJestConfig(project: JestProject, transformer: Omit<TransformerConfig, 'inner'>, cacheDirectory: string): Record<string, unknown> {
68
+ const transform: Record<string, [string, TransformerConfig]> = {};
69
+ const entries: [string, string | null, unknown][] = project.transform.length > 0 ? project.transform : [[SOURCE_PATTERN, null, {}]];
70
+ for (const [pattern, path, options] of entries) {
71
+ transform[pattern] = [TRANSFORMER, { ...transformer, inner: path ? [path, options] : null }];
72
+ }
73
+ const list = (key: string) => [project.raw[key] ?? []].flat();
74
+ return {
75
+ ...project.raw,
76
+ transform,
77
+ setupFiles: [SETUP_FILE, ...list('setupFiles')],
78
+ setupFilesAfterEnv: [HOOKS_FILE, ...list('setupFilesAfterEnv')],
79
+ cacheDirectory,
80
+ watchman: false,
81
+ collectCoverage: false,
82
+ };
83
+ }
package/src/hooks.cjs ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ // Prepended to `setupFilesAfterEnv`: attributes mutant hits to the running test
3
+ // and, in a dry run, writes the file's coverage for the session to collect.
4
+ const { createHash } = require('node:crypto');
5
+ const { writeFileSync } = require('node:fs');
6
+ const { join } = require('node:path');
7
+
8
+ const ns = globalThis.__mutator__;
9
+ // Duplicate full names get `#2`, `#3`, ... in execution (= declaration) order, like the session.
10
+ const seen = new Map();
11
+ // Test name -> processes it started (dry run only).
12
+ const spawns = {};
13
+
14
+ // Dry run only: record the processes each test starts (mutants applied in memory are not
15
+ // seen there). The builtin is shared by the worker's test files: each file re-wraps the original.
16
+ if (process.env.MUTATOR_COVERAGE_DIR) {
17
+ const { resolve } = require('node:path');
18
+ const { fileURLToPath } = require('node:url');
19
+ const childProcess = require('node:child_process');
20
+ const ORIGINAL = Symbol.for('mutator.original');
21
+ const record = (name, [command, second, third]) => {
22
+ if (ns.testId === null) return;
23
+ try {
24
+ const shell = name === 'exec' || name === 'execSync';
25
+ const list = !shell && Array.isArray(second) ? second.map(String) : [];
26
+ const options = shell || !Array.isArray(second) ? second : third;
27
+ const argv = shell ? String(command).trim().split(/\s+/) : [...(name === 'fork' ? ['node'] : []), String(command), ...list];
28
+ const cwd = options && typeof options === 'object' ? options.cwd : undefined;
29
+ (spawns[ns.testId] ??= []).push({ argv, cwd: resolve(cwd instanceof URL ? fileURLToPath(cwd) : typeof cwd === 'string' ? cwd : process.cwd()) });
30
+ } catch {
31
+ // Recording must never break the test.
32
+ }
33
+ };
34
+ for (const name of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) {
35
+ const original = childProcess[name][ORIGINAL] ?? childProcess[name];
36
+ const wrap = (fn) =>
37
+ function (...args) {
38
+ record(name, args);
39
+ return fn.apply(this, args);
40
+ };
41
+ const wrapped = wrap(original);
42
+ wrapped[ORIGINAL] = original;
43
+ // util.promisify.custom (exec, execFile)
44
+ for (const key of Object.getOwnPropertySymbols(original)) wrapped[key] = typeof original[key] === 'function' ? wrap(original[key]) : original[key];
45
+ childProcess[name] = wrapped;
46
+ }
47
+ require('node:module').syncBuiltinESMExports();
48
+ }
49
+
50
+ beforeEach(() => {
51
+ const name = expect.getState().currentTestName ?? '';
52
+ const n = (seen.get(name) ?? 0) + 1;
53
+ seen.set(name, n);
54
+ ns.testId = n > 1 ? `${name}#${n}` : name;
55
+ });
56
+
57
+ afterEach(() => {
58
+ ns.testId = null;
59
+ });
60
+
61
+ afterAll(() => {
62
+ const dir = process.env.MUTATOR_COVERAGE_DIR;
63
+ if (!dir) return;
64
+ const testPath = expect.getState().testPath;
65
+ const name = createHash('sha256').update(testPath).digest('hex').slice(0, 32);
66
+ const counts = {};
67
+ for (const [keys, n] of ns.modules ?? []) for (let i = 0; i < keys.length; i++) if (n[i]) counts[keys[i]] = (counts[keys[i]] ?? 0) + n[i];
68
+ writeFileSync(join(dir, `${name}.json`), JSON.stringify({ testPath, static: ns.cov.static, perTest: ns.cov.perTest, counts, spawns }));
69
+ });
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { type JestSessionOptions, createJestSession } from './session.ts';
2
+ export type { DryRunResult, MutantRunResult, RunMutantOptions, Session, TestLocation } from '@mizchi/mutator-core';
package/src/session.ts ADDED
@@ -0,0 +1,337 @@
1
+ // Jest session: every dry run and every mutant run is a separate Jest child
2
+ // process (native ESM via --experimental-vm-modules), so a mutant stuck in a
3
+ // synchronous loop is killed with its process group instead of freezing the CLI.
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, relative } from 'node:path';
8
+ import {
9
+ DEFAULT_HIT_LIMIT,
10
+ type DryRunResult,
11
+ HIT_LIMIT_MESSAGE,
12
+ type InstrumentOptions,
13
+ type Mutant,
14
+ type MutantRunResult,
15
+ type Session,
16
+ type SpawnedCommand,
17
+ type TestInfo,
18
+ type TestLocation,
19
+ emptyDryRun,
20
+ hash,
21
+ instrument,
22
+ numberDuplicates,
23
+ } from '@mizchi/mutator-core';
24
+ import { type JestProject, mutatorJestConfig, readJestProject } from './config.ts';
25
+
26
+ export interface JestSessionOptions {
27
+ root: string;
28
+ /** Absolute paths of the source files to mutate. */
29
+ targets: readonly string[];
30
+ /** Jest config file; defaults to Jest's own lookup from `root`. */
31
+ configFile?: string;
32
+ arid?: InstrumentOptions['arid'];
33
+ excludedMutators?: InstrumentOptions['excludedMutators'];
34
+ /** Custom mutators / ignorers, used for `mutants()` in this process. */
35
+ mutators?: InstrumentOptions['mutators'];
36
+ ignorers?: InstrumentOptions['ignorers'];
37
+ /** Absolute paths of the plugin modules providing `mutators` / `ignorers`; Jest's transformer loads them itself. */
38
+ pluginModules?: readonly string[];
39
+ }
40
+
41
+ /** Jest start-up allowance added to every mutant timeout until a run has been measured. */
42
+ const DEFAULT_STARTUP_MS = 3000;
43
+
44
+ interface AssertionResult {
45
+ fullName: string;
46
+ status: string;
47
+ duration?: number | null;
48
+ failureMessages?: string[];
49
+ }
50
+
51
+ interface SuiteResult {
52
+ name: string;
53
+ status: string;
54
+ message?: string;
55
+ assertionResults: AssertionResult[];
56
+ }
57
+
58
+ interface JsonReport {
59
+ testResults: SuiteResult[];
60
+ }
61
+
62
+ interface FileCoverage {
63
+ testPath: string;
64
+ static: Record<string, number>;
65
+ perTest: Record<string, Record<string, number>>;
66
+ /** Hits per mutant summed over the file's modules. */
67
+ counts?: Record<string, number>;
68
+ /** Test name -> processes it started. */
69
+ spawns?: Record<string, SpawnedCommand[]>;
70
+ }
71
+
72
+ interface RunOutcome {
73
+ report: JsonReport | undefined;
74
+ timedOut: boolean;
75
+ stderr: string;
76
+ wallMs: number;
77
+ }
78
+
79
+ export async function createJestSession(options: JestSessionOptions): Promise<Session> {
80
+ const { root } = options;
81
+ if ((options.mutators?.length || options.ignorers?.length) && !options.pluginModules?.length) {
82
+ throw new Error('mutator: custom mutators / ignorers reach Jest only as plugin modules (pluginModules)');
83
+ }
84
+ const project: JestProject = await readJestProject(root, options.configFile);
85
+ const dir = mkdtempSync(join(tmpdir(), 'mutator-jest-'));
86
+ const configPath = join(dir, 'jest.config.json');
87
+ const transformer = {
88
+ root,
89
+ targets: [...options.targets],
90
+ pluginModules: [...(options.pluginModules ?? [])],
91
+ ...(options.arid !== undefined ? { arid: options.arid } : {}),
92
+ ...(options.excludedMutators ? { excludedMutators: options.excludedMutators } : {}),
93
+ };
94
+ writeFileSync(configPath, JSON.stringify(mutatorJestConfig(project, transformer, join(dir, 'cache'))));
95
+
96
+ const locations = new Map<string, TestLocation>();
97
+ let mutants: Mutant[] | undefined;
98
+ let startupMs = DEFAULT_STARTUP_MS;
99
+ let runs = 0;
100
+
101
+ const jest = async (args: readonly string[], env: Record<string, string>, timeoutMs = Infinity): Promise<RunOutcome> => {
102
+ const outputFile = join(dir, `report-${runs++}.json`);
103
+ const argv = [
104
+ '--experimental-vm-modules',
105
+ '--disable-warning=ExperimentalWarning',
106
+ project.bin,
107
+ '--config',
108
+ configPath,
109
+ '--ci',
110
+ '--silent',
111
+ '--json',
112
+ '--outputFile',
113
+ outputFile,
114
+ '--passWithNoTests',
115
+ '--forceExit',
116
+ ...args,
117
+ ];
118
+ const started = performance.now();
119
+ const child = spawn(process.execPath, argv, {
120
+ cwd: root,
121
+ detached: true,
122
+ stdio: ['ignore', 'ignore', 'pipe'],
123
+ env: { ...process.env, ...env },
124
+ });
125
+ let stderr = '';
126
+ child.stderr.on('data', (chunk: Buffer) => {
127
+ if (stderr.length < 64 * 1024) stderr += chunk.toString();
128
+ });
129
+ let timedOut = false;
130
+ const timer = Number.isFinite(timeoutMs)
131
+ ? setTimeout(() => {
132
+ timedOut = true;
133
+ killGroup(child.pid);
134
+ }, timeoutMs)
135
+ : undefined;
136
+ await new Promise<void>((resolve) => child.on('close', () => resolve()));
137
+ clearTimeout(timer);
138
+ // Workers Jest forked are in the same group; make sure none outlives the run.
139
+ killGroup(child.pid);
140
+ let report: JsonReport | undefined;
141
+ try {
142
+ report = JSON.parse(readFileSync(outputFile, 'utf8')) as JsonReport;
143
+ } catch {
144
+ report = undefined;
145
+ }
146
+ rmSync(outputFile, { force: true });
147
+ return { report, timedOut, stderr, wallMs: performance.now() - started };
148
+ };
149
+
150
+ const measure = (outcome: RunOutcome) => {
151
+ if (!outcome.report) return;
152
+ const testsMs = outcome.report.testResults.flatMap((s) => s.assertionResults).reduce((sum, a) => sum + (a.duration ?? 0), 0);
153
+ startupMs = Math.max(1000, outcome.wallMs - testsMs);
154
+ };
155
+
156
+ return {
157
+ mutants() {
158
+ mutants ??= options.targets.flatMap(
159
+ (file) =>
160
+ instrument(file, readFileSync(file, 'utf8'), {
161
+ identity: relative(root, file),
162
+ ...(options.arid !== undefined ? { arid: options.arid } : {}),
163
+ ...(options.excludedMutators ? { excludedMutators: options.excludedMutators } : {}),
164
+ ...(options.mutators ? { mutators: options.mutators } : {}),
165
+ ...(options.ignorers ? { ignorers: options.ignorers } : {}),
166
+ }).mutants,
167
+ );
168
+ return mutants;
169
+ },
170
+
171
+ configFiles() {
172
+ return [...new Set([...(project.configPath ? [project.configPath] : []), ...project.setupFiles])].sort();
173
+ },
174
+
175
+ async testFiles() {
176
+ return listTests(project, configPath, root);
177
+ },
178
+
179
+ async dryRun(files) {
180
+ if (files && files.length === 0) return emptyDryRun();
181
+ const coverageDir = join(dir, `coverage-${runs}`);
182
+ mkdirSync(coverageDir, { recursive: true });
183
+ const outcome = await jest(files ? ['--runTestsByPath', ...files] : [], {
184
+ MUTATOR_COVERAGE_DIR: coverageDir,
185
+ MUTATOR_HIT_LIMIT: String(DEFAULT_HIT_LIMIT),
186
+ });
187
+ if (!outcome.report) throw new Error(`mutator: jest produced no report\n${outcome.stderr}`);
188
+ measure(outcome);
189
+ const coverage = new Map<string, FileCoverage>();
190
+ for (const name of readdirSync(coverageDir)) {
191
+ const file = JSON.parse(readFileSync(join(coverageDir, name), 'utf8')) as FileCoverage;
192
+ coverage.set(file.testPath, file);
193
+ }
194
+ rmSync(coverageDir, { recursive: true, force: true });
195
+ const dry = collectDryRun(outcome.report, coverage, root);
196
+ for (const [id, location] of dry.index) locations.set(id, location);
197
+ return dry;
198
+ },
199
+
200
+ useTestIndex(index) {
201
+ locations.clear();
202
+ for (const [id, location] of index) locations.set(id, location);
203
+ },
204
+
205
+ async runMutant(key, testIds, { timeoutMs, hitLimit = DEFAULT_HIT_LIMIT, earlyExit = true }) {
206
+ const byFile = new Map<string, Set<string>>();
207
+ for (const id of testIds) {
208
+ const location = locations.get(id);
209
+ if (!location) continue;
210
+ const names = byFile.get(location.moduleId) ?? new Set();
211
+ names.add(location.taskId);
212
+ byFile.set(location.moduleId, names);
213
+ }
214
+ if (byFile.size === 0) return { status: 'Survived', killedBy: [], durationMs: 0 };
215
+ const names = [...new Set([...byFile.values()].flatMap((s) => [...s]))];
216
+ const pattern = `^(?:${names.map(escapeRegExp).join('|')})$`;
217
+ const outcome = await jest(
218
+ // --bail stops after the first failing test file; without it every file runs (--kill-matrix).
219
+ ['--runInBand', ...(earlyExit ? ['--bail'] : []), '--testNamePattern', pattern, '--runTestsByPath', ...byFile.keys()],
220
+ { MUTATOR_ACTIVE: key, MUTATOR_HIT_LIMIT: String(hitLimit) },
221
+ timeoutMs + startupMs,
222
+ );
223
+ const durationMs = outcome.wallMs;
224
+ if (outcome.timedOut) return { status: 'Timeout', killedBy: [], durationMs };
225
+ if (!outcome.report) return { status: 'RuntimeError', killedBy: [], durationMs };
226
+ measure(outcome);
227
+ return { ...classify(outcome.report, root, new Set(testIds)), durationMs };
228
+ },
229
+
230
+ async close() {
231
+ rmSync(dir, { recursive: true, force: true });
232
+ },
233
+ };
234
+ }
235
+
236
+ function killGroup(pid: number | undefined): void {
237
+ if (pid === undefined) return;
238
+ try {
239
+ process.kill(-pid, 'SIGKILL');
240
+ } catch {
241
+ // already gone
242
+ }
243
+ }
244
+
245
+ async function listTests(project: JestProject, configPath: string, root: string): Promise<string[]> {
246
+ const child = spawn(
247
+ process.execPath,
248
+ ['--experimental-vm-modules', '--disable-warning=ExperimentalWarning', project.bin, '--config', configPath, '--listTests', '--json'],
249
+ { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] },
250
+ );
251
+ let stdout = '';
252
+ let stderr = '';
253
+ child.stdout.on('data', (c: Buffer) => (stdout += c.toString()));
254
+ child.stderr.on('data', (c: Buffer) => (stderr += c.toString()));
255
+ const code = await new Promise<number | null>((resolve) => child.on('close', resolve));
256
+ const json = stdout.slice(stdout.indexOf('['));
257
+ try {
258
+ return (JSON.parse(json) as string[]).sort();
259
+ } catch {
260
+ throw new Error(`mutator: jest --listTests failed (exit ${code})\n${stderr}`);
261
+ }
262
+ }
263
+
264
+ function escapeRegExp(s: string): string {
265
+ return s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
266
+ }
267
+
268
+ const SKIPPED = new Set(['pending', 'skipped', 'todo', 'disabled', 'focused']);
269
+
270
+ /** Test ids with `#n` suffixes for duplicate full names, as the vitest session does. */
271
+ function* identify(suite: SuiteResult, root: string): Generator<{ id: string; assertion: AssertionResult; coverageKey: string }> {
272
+ const rel = relative(root, suite.name);
273
+ const distinct = numberDuplicates();
274
+ const executed = numberDuplicates();
275
+ for (const assertion of suite.assertionResults) {
276
+ const id = distinct(`${rel}#${assertion.fullName}`);
277
+ // The setup hooks only see executed tests; their counter skips skipped ones.
278
+ const coverageKey = SKIPPED.has(assertion.status) ? assertion.fullName : executed(assertion.fullName);
279
+ yield { id, assertion, coverageKey };
280
+ }
281
+ }
282
+
283
+ function collectDryRun(report: JsonReport, coverageByFile: ReadonlyMap<string, FileCoverage>, root: string): DryRunResult {
284
+ const dry = emptyDryRun();
285
+ for (const suite of report.testResults) {
286
+ const rel = relative(root, suite.name);
287
+ const cov = coverageByFile.get(suite.name);
288
+ const fileStatic = new Set<string>();
289
+ dry.staticByFile.set(rel, fileStatic);
290
+ for (const key of Object.keys(cov?.static ?? {})) {
291
+ dry.staticKeys.add(key);
292
+ fileStatic.add(key);
293
+ }
294
+ for (const [key, n] of Object.entries(cov?.counts ?? {})) dry.hits.set(key, (dry.hits.get(key) ?? 0) + n);
295
+ if (suite.status === 'failed' && !suite.assertionResults.some((a) => a.status === 'failed')) dry.failed.push(rel);
296
+ const fingerprint = hash(readFileSync(suite.name, 'utf8'));
297
+ for (const { id, assertion, coverageKey } of identify(suite, root)) {
298
+ dry.index.set(id, { moduleId: suite.name, taskId: assertion.fullName });
299
+ if (SKIPPED.has(assertion.status)) continue;
300
+ if (assertion.status === 'failed') dry.failed.push(id);
301
+ const test: TestInfo = { id, fingerprint: hash(`${fingerprint}\0${id}`), durationMs: assertion.duration ?? 0 };
302
+ dry.tests.push(test);
303
+ const spawned = cov?.spawns?.[coverageKey];
304
+ if (spawned) (dry.spawns ??= new Map()).set(id, spawned);
305
+ for (const key of Object.keys(cov?.perTest[coverageKey] ?? {})) {
306
+ const covering = dry.coverage.get(key) ?? [];
307
+ covering.push(id);
308
+ dry.coverage.set(key, covering);
309
+ }
310
+ }
311
+ }
312
+ return dry;
313
+ }
314
+
315
+ function classify(report: JsonReport, root: string, selected: ReadonlySet<string>): Omit<MutantRunResult, 'durationMs'> {
316
+ const killedBy: string[] = [];
317
+ const messages: string[] = [];
318
+ let suiteError = false;
319
+ for (const suite of report.testResults) {
320
+ const failedTests = suite.assertionResults.some((a) => a.status === 'failed');
321
+ if (suite.status === 'failed' && !failedTests) {
322
+ suiteError = true;
323
+ killedBy.push(relative(root, suite.name));
324
+ messages.push(suite.message ?? '');
325
+ }
326
+ for (const { id, assertion } of identify(suite, root)) {
327
+ if (assertion.status !== 'failed' || !selected.has(id)) continue;
328
+ killedBy.push(id);
329
+ messages.push((assertion.failureMessages ?? []).join('\n'));
330
+ }
331
+ }
332
+ if (killedBy.length === 0) return { status: 'Survived', killedBy };
333
+ // The hit limit guards against endless loops: a run stopped only by it is a timeout.
334
+ if (messages.every((m) => m.includes(HIT_LIMIT_MESSAGE))) return { status: 'Timeout', killedBy: [] };
335
+ if (suiteError && killedBy.every((k) => !k.includes('#'))) return { status: 'RuntimeError', killedBy };
336
+ return { status: 'Killed', killedBy };
337
+ }
package/src/setup.cjs ADDED
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ // Prepended to `setupFiles`: runs in each test file's environment before any
3
+ // module under test, so static (module-level) mutants already see the active key.
4
+ const limit = Number(process.env.MUTATOR_HIT_LIMIT);
5
+ globalThis.__mutator__ = {
6
+ active: process.env.MUTATOR_ACTIVE || null,
7
+ // Coverage is only needed by the dry run; mutant runs skip the counters entirely.
8
+ collect: !process.env.MUTATOR_ACTIVE,
9
+ modules: [],
10
+ cov: { static: {}, perTest: {} },
11
+ testId: null,
12
+ hits: 0,
13
+ hitLimit: Number.isFinite(limit) && limit > 0 ? limit : Infinity,
14
+ };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+ // Jest transformer wrapping the project's own transformer: target files are
3
+ // instrumented with @mizchi/mutator-core first, then handed to the inner
4
+ // transformer unchanged in module form (ESM stays ESM). Without an inner
5
+ // transformer, TypeScript is stripped with Node's built-in type stripping.
6
+ // CommonJS because Jest loads transformers with require.
7
+ const { createHash } = require('node:crypto');
8
+ const { stripTypeScriptTypes } = require('node:module');
9
+ const { relative } = require('node:path');
10
+ const { pathToFileURL } = require('node:url');
11
+
12
+ const TS = /\.[cm]?tsx?$/;
13
+
14
+ async function load(file) {
15
+ let mod;
16
+ try {
17
+ mod = require(file);
18
+ } catch (error) {
19
+ if (error && error.code !== 'ERR_REQUIRE_ESM' && error.code !== 'ERR_REQUIRE_ASYNC_MODULE') throw error;
20
+ mod = await import(pathToFileURL(file).href);
21
+ }
22
+ return mod;
23
+ }
24
+
25
+ function isTransformer(value) {
26
+ return value && (typeof value.process === 'function' || typeof value.processAsync === 'function' || typeof value.createTransformer === 'function');
27
+ }
28
+
29
+ async function loadInner(inner) {
30
+ if (!inner) return undefined;
31
+ const [file, options] = inner;
32
+ let mod = await load(file);
33
+ if (!isTransformer(mod) && isTransformer(mod && mod.default)) mod = mod.default;
34
+ if (!isTransformer(mod)) throw new Error(`mutator: ${file} is not a Jest transformer`);
35
+ return typeof mod.createTransformer === 'function' ? await mod.createTransformer(options) : mod;
36
+ }
37
+
38
+ /** Plugin modules (absolute paths) -> custom mutators and ignorers, like the CLI's loader. */
39
+ async function loadPlugins(files) {
40
+ const mutators = [];
41
+ const ignorers = [];
42
+ const add = (value, origin) => {
43
+ if (Array.isArray(value)) return value.forEach((v) => add(v, origin));
44
+ if (value && typeof value.visit === 'function') return void mutators.push(value);
45
+ if (value && typeof value.shouldIgnore === 'function') return void ignorers.push(value);
46
+ if (value && typeof value.name === 'string') {
47
+ for (const m of value.mutators || []) add(m, origin);
48
+ for (const i of value.ignorers || []) add(i, origin);
49
+ return;
50
+ }
51
+ throw new Error(`mutator: plugin ${origin} has no plugin, mutator or ignorer as its default export`);
52
+ };
53
+ for (const file of files) {
54
+ const mod = await import(pathToFileURL(file).href);
55
+ add(mod.default ?? mod, file);
56
+ }
57
+ return { mutators, ignorers };
58
+ }
59
+
60
+ async function createTransformer(config) {
61
+ const { instrument } = await import('@mizchi/mutator-core');
62
+ const { mutators, ignorers } = await loadPlugins(config.pluginModules || []);
63
+ const inner = await loadInner(config.inner);
64
+ const targets = new Set(config.targets);
65
+ const instrumentOptions = {
66
+ ...(config.arid !== undefined ? { arid: config.arid } : {}),
67
+ ...(config.excludedMutators ? { excludedMutators: config.excludedMutators } : {}),
68
+ ...(mutators.length ? { mutators } : {}),
69
+ ...(ignorers.length ? { ignorers } : {}),
70
+ };
71
+ const innerOptions = (options) => ({ ...options, transformerConfig: config.inner ? config.inner[1] : {} });
72
+ const fingerprint = JSON.stringify({ ...config, targets: undefined });
73
+
74
+ const prepare = (source, file) => {
75
+ if (!targets.has(file)) return { code: source, map: undefined };
76
+ const result = instrument(file, source, { ...instrumentOptions, identity: relative(config.root, file) });
77
+ return { code: result.code, map: result.map };
78
+ };
79
+ const finish = (prepared, file) => {
80
+ if (!TS.test(file)) return prepared;
81
+ // Strip mode only blanks out types, so the instrumentation's sourcemap stays valid.
82
+ return { code: stripTypeScriptTypes(prepared.code, { mode: 'strip' }), map: prepared.map };
83
+ };
84
+ const cacheKey = (source, file, innerKey) =>
85
+ createHash('sha256')
86
+ .update([fingerprint, file, String(targets.has(file)), source, innerKey || ''].join('\0'))
87
+ .digest('hex');
88
+
89
+ return {
90
+ canInstrument: false,
91
+ getCacheKey(source, file, options) {
92
+ const innerKey = inner && typeof inner.getCacheKey === 'function' ? inner.getCacheKey(source, file, innerOptions(options)) : '';
93
+ return cacheKey(source, file, innerKey);
94
+ },
95
+ async getCacheKeyAsync(source, file, options) {
96
+ const get = inner && (inner.getCacheKeyAsync || inner.getCacheKey);
97
+ const innerKey = get ? await get.call(inner, source, file, innerOptions(options)) : '';
98
+ return cacheKey(source, file, innerKey);
99
+ },
100
+ process(source, file, options) {
101
+ const prepared = prepare(source, file);
102
+ if (!inner) return finish(prepared, file);
103
+ if (typeof inner.process !== 'function') throw new Error(`mutator: the transformer for ${file} only supports processAsync`);
104
+ return inner.process(prepared.code, file, innerOptions(options));
105
+ },
106
+ async processAsync(source, file, options) {
107
+ const prepared = prepare(source, file);
108
+ if (!inner) return finish(prepared, file);
109
+ const run = inner.processAsync || inner.process;
110
+ return run.call(inner, prepared.code, file, innerOptions(options));
111
+ },
112
+ };
113
+ }
114
+
115
+ module.exports = { createTransformer };
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.