@mutineerjs/mutineer 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.
Files changed (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -0
  3. package/dist/admin/assets/index-B7nXq-e7.js +32 -0
  4. package/dist/admin/assets/index-B7nXq-e7.js.map +1 -0
  5. package/dist/admin/assets/index-BDQLkBUE.js +32 -0
  6. package/dist/admin/assets/index-BDQLkBUE.js.map +1 -0
  7. package/dist/admin/assets/index-DVkP-Tc7.css +1 -0
  8. package/dist/admin/index.html +13 -0
  9. package/dist/admin/server/admin.d.ts +6 -0
  10. package/dist/admin/server/admin.js +234 -0
  11. package/dist/bin/mutate-vitest.d.ts +2 -0
  12. package/dist/bin/mutate-vitest.js +90 -0
  13. package/dist/bin/mutineer.d.ts +2 -0
  14. package/dist/bin/mutineer.js +46 -0
  15. package/dist/core/__tests__/module.spec.d.ts +1 -0
  16. package/dist/core/__tests__/module.spec.js +6 -0
  17. package/dist/core/module.d.ts +11 -0
  18. package/dist/core/module.js +14 -0
  19. package/dist/core/sfc.d.ts +12 -0
  20. package/dist/core/sfc.js +54 -0
  21. package/dist/core/types.d.ts +6 -0
  22. package/dist/core/types.js +1 -0
  23. package/dist/core/variant-utils.d.ts +30 -0
  24. package/dist/core/variant-utils.js +54 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.js +3 -0
  27. package/dist/mutators/__tests__/registry.spec.d.ts +1 -0
  28. package/dist/mutators/__tests__/registry.spec.js +43 -0
  29. package/dist/mutators/__tests__/utils.spec.d.ts +1 -0
  30. package/dist/mutators/__tests__/utils.spec.js +15 -0
  31. package/dist/mutators/registry.d.ts +37 -0
  32. package/dist/mutators/registry.js +101 -0
  33. package/dist/mutators/types.d.ts +39 -0
  34. package/dist/mutators/types.js +7 -0
  35. package/dist/mutators/utils.d.ts +37 -0
  36. package/dist/mutators/utils.js +151 -0
  37. package/dist/plugin/viteMutate.d.ts +15 -0
  38. package/dist/plugin/viteMutate.js +52 -0
  39. package/dist/plugin/vitest.setup.d.ts +47 -0
  40. package/dist/plugin/vitest.setup.js +118 -0
  41. package/dist/plugin/withVitest.d.ts +13 -0
  42. package/dist/plugin/withVitest.js +30 -0
  43. package/dist/runner/__tests__/discover.spec.d.ts +1 -0
  44. package/dist/runner/__tests__/discover.spec.js +59 -0
  45. package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
  46. package/dist/runner/__tests__/orchestrator.spec.js +55 -0
  47. package/dist/runner/adapters/__tests__/jest.spec.d.ts +1 -0
  48. package/dist/runner/adapters/__tests__/jest.spec.js +88 -0
  49. package/dist/runner/adapters/__tests__/vitest-worker-runtime.spec.d.ts +1 -0
  50. package/dist/runner/adapters/__tests__/vitest-worker-runtime.spec.js +59 -0
  51. package/dist/runner/adapters/__tests__/vitest.spec.d.ts +1 -0
  52. package/dist/runner/adapters/__tests__/vitest.spec.js +118 -0
  53. package/dist/runner/adapters/index.d.ts +10 -0
  54. package/dist/runner/adapters/index.js +9 -0
  55. package/dist/runner/adapters/jest/__tests__/index.spec.d.ts +1 -0
  56. package/dist/runner/adapters/jest/__tests__/index.spec.js +88 -0
  57. package/dist/runner/adapters/jest/index.d.ts +24 -0
  58. package/dist/runner/adapters/jest/index.js +216 -0
  59. package/dist/runner/adapters/jest/worker-runtime.d.ts +37 -0
  60. package/dist/runner/adapters/jest/worker-runtime.js +171 -0
  61. package/dist/runner/adapters/jest-worker-runtime.d.ts +37 -0
  62. package/dist/runner/adapters/jest-worker-runtime.js +171 -0
  63. package/dist/runner/adapters/jest.d.ts +24 -0
  64. package/dist/runner/adapters/jest.js +216 -0
  65. package/dist/runner/adapters/types.d.ts +89 -0
  66. package/dist/runner/adapters/types.js +8 -0
  67. package/dist/runner/adapters/vitest/__tests__/index.spec.d.ts +1 -0
  68. package/dist/runner/adapters/vitest/__tests__/index.spec.js +118 -0
  69. package/dist/runner/adapters/vitest/__tests__/worker-runtime.spec.d.ts +1 -0
  70. package/dist/runner/adapters/vitest/__tests__/worker-runtime.spec.js +59 -0
  71. package/dist/runner/adapters/vitest/index.d.ts +33 -0
  72. package/dist/runner/adapters/vitest/index.js +267 -0
  73. package/dist/runner/adapters/vitest/worker-runtime.d.ts +25 -0
  74. package/dist/runner/adapters/vitest/worker-runtime.js +118 -0
  75. package/dist/runner/adapters/vitest-worker-runtime.d.ts +25 -0
  76. package/dist/runner/adapters/vitest-worker-runtime.js +118 -0
  77. package/dist/runner/adapters/vitest.d.ts +33 -0
  78. package/dist/runner/adapters/vitest.js +267 -0
  79. package/dist/runner/args.d.ts +50 -0
  80. package/dist/runner/args.js +123 -0
  81. package/dist/runner/cache.d.ts +38 -0
  82. package/dist/runner/cache.js +118 -0
  83. package/dist/runner/changed.d.ts +22 -0
  84. package/dist/runner/changed.js +210 -0
  85. package/dist/runner/cleanup.d.ts +4 -0
  86. package/dist/runner/cleanup.js +21 -0
  87. package/dist/runner/config.d.ts +13 -0
  88. package/dist/runner/config.js +94 -0
  89. package/dist/runner/discover.d.ts +7 -0
  90. package/dist/runner/discover.js +258 -0
  91. package/dist/runner/jest/__tests__/adapter.spec.d.ts +1 -0
  92. package/dist/runner/jest/__tests__/adapter.spec.js +110 -0
  93. package/dist/runner/jest/adapter.d.ts +24 -0
  94. package/dist/runner/jest/adapter.js +191 -0
  95. package/dist/runner/jest/index.d.ts +8 -0
  96. package/dist/runner/jest/index.js +7 -0
  97. package/dist/runner/jest/pool.d.ts +47 -0
  98. package/dist/runner/jest/pool.js +307 -0
  99. package/dist/runner/jest/resolver.cjs +61 -0
  100. package/dist/runner/jest/resolver.d.cts +11 -0
  101. package/dist/runner/jest/worker-runtime.d.ts +30 -0
  102. package/dist/runner/jest/worker-runtime.js +98 -0
  103. package/dist/runner/jest/worker.d.mts +1 -0
  104. package/dist/runner/jest/worker.mjs +55 -0
  105. package/dist/runner/orchestrator.d.ts +13 -0
  106. package/dist/runner/orchestrator.js +387 -0
  107. package/dist/runner/pool/__tests__/index.spec.d.ts +1 -0
  108. package/dist/runner/pool/__tests__/index.spec.js +83 -0
  109. package/dist/runner/pool/__tests__/pool-plugin.spec.d.ts +1 -0
  110. package/dist/runner/pool/__tests__/pool-plugin.spec.js +59 -0
  111. package/dist/runner/pool/__tests__/pool-redirect-loader.spec.d.ts +1 -0
  112. package/dist/runner/pool/__tests__/pool-redirect-loader.spec.js +78 -0
  113. package/dist/runner/pool/index.d.ts +8 -0
  114. package/dist/runner/pool/index.js +9 -0
  115. package/dist/runner/pool/jest/pool.d.ts +52 -0
  116. package/dist/runner/pool/jest/pool.js +309 -0
  117. package/dist/runner/pool/jest/worker.d.mts +1 -0
  118. package/dist/runner/pool/jest/worker.mjs +60 -0
  119. package/dist/runner/pool/jest-pool.d.ts +52 -0
  120. package/dist/runner/pool/jest-pool.js +309 -0
  121. package/dist/runner/pool/jest-worker.d.mts +1 -0
  122. package/dist/runner/pool/jest-worker.mjs +60 -0
  123. package/dist/runner/pool/plugin.d.ts +18 -0
  124. package/dist/runner/pool/plugin.js +60 -0
  125. package/dist/runner/pool/pool-plugin.d.ts +18 -0
  126. package/dist/runner/pool/pool-plugin.js +60 -0
  127. package/dist/runner/pool/pool-redirect-loader.d.ts +19 -0
  128. package/dist/runner/pool/pool-redirect-loader.js +116 -0
  129. package/dist/runner/pool/pool-redirect-loader.mjs +146 -0
  130. package/dist/runner/pool/redirect-loader.d.ts +19 -0
  131. package/dist/runner/pool/redirect-loader.js +116 -0
  132. package/dist/runner/pool/vitest/pool.d.ts +70 -0
  133. package/dist/runner/pool/vitest/pool.js +376 -0
  134. package/dist/runner/pool/vitest/worker.d.mts +15 -0
  135. package/dist/runner/pool/vitest/worker.mjs +96 -0
  136. package/dist/runner/pool/vitest-worker.d.mts +15 -0
  137. package/dist/runner/pool/vitest-worker.mjs +96 -0
  138. package/dist/runner/shared/index.d.ts +9 -0
  139. package/dist/runner/shared/index.js +8 -0
  140. package/dist/runner/shared/mutant-paths.d.ts +15 -0
  141. package/dist/runner/shared/mutant-paths.js +30 -0
  142. package/dist/runner/shared/redirect-state.d.ts +45 -0
  143. package/dist/runner/shared/redirect-state.js +50 -0
  144. package/dist/runner/shared-module-redirect.d.ts +56 -0
  145. package/dist/runner/shared-module-redirect.js +84 -0
  146. package/dist/runner/types.d.ts +88 -0
  147. package/dist/runner/types.js +8 -0
  148. package/dist/runner/variants.d.ts +21 -0
  149. package/dist/runner/variants.js +66 -0
  150. package/dist/runner/vitest/__tests__/adapter.spec.d.ts +1 -0
  151. package/dist/runner/vitest/__tests__/adapter.spec.js +131 -0
  152. package/dist/runner/vitest/__tests__/plugin.spec.d.ts +1 -0
  153. package/dist/runner/vitest/__tests__/plugin.spec.js +65 -0
  154. package/dist/runner/vitest/__tests__/pool.spec.d.ts +1 -0
  155. package/dist/runner/vitest/__tests__/pool.spec.js +106 -0
  156. package/dist/runner/vitest/__tests__/redirect-loader.spec.d.ts +1 -0
  157. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +87 -0
  158. package/dist/runner/vitest/__tests__/worker-runtime.spec.d.ts +1 -0
  159. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +75 -0
  160. package/dist/runner/vitest/adapter.d.ts +33 -0
  161. package/dist/runner/vitest/adapter.js +277 -0
  162. package/dist/runner/vitest/index.d.ts +11 -0
  163. package/dist/runner/vitest/index.js +10 -0
  164. package/dist/runner/vitest/plugin.d.ts +12 -0
  165. package/dist/runner/vitest/plugin.js +49 -0
  166. package/dist/runner/vitest/pool.d.ts +65 -0
  167. package/dist/runner/vitest/pool.js +376 -0
  168. package/dist/runner/vitest/redirect-loader.d.ts +30 -0
  169. package/dist/runner/vitest/redirect-loader.js +123 -0
  170. package/dist/runner/vitest/worker-runtime.d.ts +16 -0
  171. package/dist/runner/vitest/worker-runtime.js +105 -0
  172. package/dist/runner/vitest/worker.d.mts +15 -0
  173. package/dist/runner/vitest/worker.mjs +92 -0
  174. package/dist/types/api.d.ts +20 -0
  175. package/dist/types/api.js +1 -0
  176. package/dist/types/config.d.ts +48 -0
  177. package/dist/types/config.js +1 -0
  178. package/dist/types/index.d.ts +13 -0
  179. package/dist/types/index.js +11 -0
  180. package/dist/types/mutant.d.ts +44 -0
  181. package/dist/types/mutant.js +7 -0
  182. package/dist/utils/PoolSpinner.d.ts +5 -0
  183. package/dist/utils/PoolSpinner.js +6 -0
  184. package/dist/utils/ProgressBar.d.ts +11 -0
  185. package/dist/utils/ProgressBar.js +9 -0
  186. package/dist/utils/__tests__/coverage.spec.d.ts +1 -0
  187. package/dist/utils/__tests__/coverage.spec.js +91 -0
  188. package/dist/utils/__tests__/progress.spec.d.ts +1 -0
  189. package/dist/utils/__tests__/progress.spec.js +50 -0
  190. package/dist/utils/__tests__/summary.spec.d.ts +1 -0
  191. package/dist/utils/__tests__/summary.spec.js +54 -0
  192. package/dist/utils/coverage.d.ts +57 -0
  193. package/dist/utils/coverage.js +204 -0
  194. package/dist/utils/logger.d.ts +8 -0
  195. package/dist/utils/logger.js +18 -0
  196. package/dist/utils/progress.d.ts +25 -0
  197. package/dist/utils/progress.js +90 -0
  198. package/dist/utils/summary.d.ts +12 -0
  199. package/dist/utils/summary.js +107 -0
  200. package/package.json +59 -0
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ /**
3
+ * Jest custom resolver for mutineer.
4
+ *
5
+ * This resolver intercepts module resolution to redirect imports of the original
6
+ * source file to the mutated version during test execution.
7
+ *
8
+ * The redirect configuration is passed via environment variables:
9
+ * - MUTINEER_REDIRECT_FROM: Absolute path to the original file
10
+ * - MUTINEER_REDIRECT_TO: Absolute path to the mutant file
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ const path = require('path');
14
+ /**
15
+ * Custom Jest resolver that redirects module resolution for mutant testing.
16
+ */
17
+ module.exports = (request, options) => {
18
+ // Get redirect configuration from environment
19
+ const from = process.env.MUTINEER_REDIRECT_FROM;
20
+ const to = process.env.MUTINEER_REDIRECT_TO;
21
+ const normalizedFrom = from ? path.resolve(from) : null;
22
+ // Helper to resolve using Jest's default resolver
23
+ const resolveWith = (req, opts) => {
24
+ if (options?.defaultResolver) {
25
+ return options.defaultResolver(req, opts);
26
+ }
27
+ // Fallback to require.resolve if no default resolver provided
28
+ return require.resolve(req, { paths: opts?.paths || [process.cwd()] });
29
+ };
30
+ // Helper to check if a path matches the redirect source
31
+ const matchesFrom = (p) => {
32
+ if (!normalizedFrom)
33
+ return false;
34
+ const abs = path.resolve(p);
35
+ if (abs === normalizedFrom)
36
+ return true;
37
+ // Handle imports without extensions (e.g., './foo' resolving to './foo.ts')
38
+ const withExt = path.extname(abs) ? abs : abs + path.extname(normalizedFrom);
39
+ return path.resolve(withExt) === normalizedFrom;
40
+ };
41
+ // Try to resolve the request normally first
42
+ let resolved;
43
+ try {
44
+ resolved = resolveWith(request, options);
45
+ }
46
+ catch {
47
+ resolved = null;
48
+ }
49
+ // Check if the request itself (before resolution) matches
50
+ const baseDir = options?.basedir ?? process.cwd();
51
+ const candidate = path.resolve(baseDir, request);
52
+ // If either the candidate or the resolved path matches our redirect source,
53
+ // return the mutant file path
54
+ if (normalizedFrom &&
55
+ to &&
56
+ (matchesFrom(candidate) || (resolved && matchesFrom(resolved)))) {
57
+ return to;
58
+ }
59
+ // Otherwise, return the normally resolved path (or resolve again if it failed before)
60
+ return resolved ?? resolveWith(request, options);
61
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Jest custom resolver for mutineer.
3
+ *
4
+ * This resolver intercepts module resolution to redirect imports of the original
5
+ * source file to the mutated version during test execution.
6
+ *
7
+ * The redirect configuration is passed via environment variables:
8
+ * - MUTINEER_REDIRECT_FROM: Absolute path to the original file
9
+ * - MUTINEER_REDIRECT_TO: Absolute path to the mutant file
10
+ */
11
+ export {};
@@ -0,0 +1,30 @@
1
+ import type { MutantPayload } from '../types.js';
2
+ import type { MutantRunSummary } from '../../types/mutant.js';
3
+ interface JestTestResult {
4
+ failureMessage?: string;
5
+ }
6
+ interface JestAggregatedResult {
7
+ success: boolean;
8
+ numTotalTests?: number;
9
+ testResults?: JestTestResult[];
10
+ }
11
+ export type JestRunCLI = (argv: Record<string, unknown>, projects: string[]) => Promise<{
12
+ results: JestAggregatedResult;
13
+ globalConfig: unknown;
14
+ }>;
15
+ export interface JestWorkerRuntimeOptions {
16
+ workerId: string;
17
+ cwd: string;
18
+ jestConfigPath?: string;
19
+ }
20
+ export declare class JestWorkerRuntime {
21
+ private readonly options;
22
+ private readonly resolverPath;
23
+ private readonly requireFromCwd;
24
+ constructor(options: JestWorkerRuntimeOptions);
25
+ init(): Promise<void>;
26
+ shutdown(): Promise<void>;
27
+ run(mutant: MutantPayload, tests: string[]): Promise<MutantRunSummary>;
28
+ }
29
+ export declare function createJestWorkerRuntime(options: JestWorkerRuntimeOptions): JestWorkerRuntime;
30
+ export {};
@@ -0,0 +1,98 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { getMutantFilePath, setRedirect, clearRedirect, } from '../shared/index.js';
6
+ import { createLogger } from '../../utils/logger.js';
7
+ const log = createLogger('jest-runtime');
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+ /**
11
+ * Get the path to the pre-built Jest resolver module.
12
+ */
13
+ function getResolverPath() {
14
+ // The resolver.cjs is in the same directory as this file
15
+ return path.join(__dirname, 'resolver.cjs');
16
+ }
17
+ async function loadRunCLI(requireFromCwd) {
18
+ try {
19
+ return requireFromCwd('@jest/core');
20
+ }
21
+ catch {
22
+ return import('@jest/core');
23
+ }
24
+ }
25
+ export class JestWorkerRuntime {
26
+ constructor(options) {
27
+ this.options = options;
28
+ this.requireFromCwd = createRequire(path.join(options.cwd, 'package.json'));
29
+ this.resolverPath = getResolverPath();
30
+ }
31
+ async init() {
32
+ // Resolver is pre-built, no initialization needed
33
+ }
34
+ async shutdown() {
35
+ // Resolver is pre-built, no cleanup needed
36
+ }
37
+ async run(mutant, tests) {
38
+ const start = Date.now();
39
+ try {
40
+ const mutantPath = getMutantFilePath(mutant.file, mutant.id);
41
+ fs.writeFileSync(mutantPath, mutant.code, 'utf8');
42
+ const redirectFrom = path.resolve(mutant.file);
43
+ setRedirect({ from: redirectFrom, to: mutantPath });
44
+ process.env.MUTINEER_REDIRECT_FROM = redirectFrom;
45
+ process.env.MUTINEER_REDIRECT_TO = mutantPath;
46
+ const cliOptions = {
47
+ _: [...tests],
48
+ $0: 'mutineer',
49
+ runInBand: true,
50
+ runTestsByPath: true,
51
+ testPathPattern: [...tests],
52
+ watch: false,
53
+ passWithNoTests: true,
54
+ resolver: this.resolverPath,
55
+ silent: true,
56
+ };
57
+ if (this.options.jestConfigPath) {
58
+ cliOptions.config = this.options.jestConfigPath;
59
+ }
60
+ const { runCLI } = await loadRunCLI(this.requireFromCwd);
61
+ const { results } = await runCLI(cliOptions, [this.options.cwd]);
62
+ const killed = !results.success;
63
+ const failureMessages = results.testResults
64
+ ?.map((r) => r.failureMessage)
65
+ .filter(Boolean)
66
+ .join('\n');
67
+ log.debug(`runCLI success=${results.success} tests=${results.numTotalTests ?? 'n/a'}`);
68
+ return {
69
+ killed,
70
+ durationMs: Date.now() - start,
71
+ error: failureMessages || undefined,
72
+ };
73
+ }
74
+ catch (err) {
75
+ log.debug(`runCLI error: ${err}`);
76
+ return {
77
+ killed: true,
78
+ durationMs: Date.now() - start,
79
+ error: err instanceof Error ? err.message : String(err),
80
+ };
81
+ }
82
+ finally {
83
+ const mutantPath = getMutantFilePath(mutant.file, mutant.id);
84
+ clearRedirect();
85
+ delete process.env.MUTINEER_REDIRECT_FROM;
86
+ delete process.env.MUTINEER_REDIRECT_TO;
87
+ try {
88
+ fs.rmSync(mutantPath, { force: true });
89
+ }
90
+ catch {
91
+ // ignore
92
+ }
93
+ }
94
+ }
95
+ }
96
+ export function createJestWorkerRuntime(options) {
97
+ return new JestWorkerRuntime(options);
98
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { createJestWorkerRuntime } from './worker-runtime.js';
2
+ import { createLogger } from '../../utils/logger.js';
3
+ const log = createLogger('jest-worker');
4
+ globalThis.__mutineer_redirect__ = { from: null, to: null };
5
+ async function main() {
6
+ const workerId = process.env.MUTINEER_WORKER_ID ?? 'unknown';
7
+ const cwd = process.env.MUTINEER_CWD ?? process.cwd();
8
+ const jestConfigPath = process.env.MUTINEER_JEST_CONFIG;
9
+ log.debug(`Starting worker ${workerId} in ${cwd}`);
10
+ const runtime = createJestWorkerRuntime({
11
+ workerId,
12
+ cwd,
13
+ jestConfigPath,
14
+ });
15
+ try {
16
+ await runtime.init();
17
+ }
18
+ catch (err) {
19
+ log.error(`Failed to initialize: ${err}`);
20
+ process.exit(1);
21
+ }
22
+ process.send?.({ type: 'ready', workerId });
23
+ process.on('message', async (raw) => {
24
+ if (raw.type === 'shutdown') {
25
+ log.debug('Shutting down');
26
+ await runtime.shutdown();
27
+ process.send?.({ type: 'shutdown', ok: true });
28
+ process.exit(0);
29
+ }
30
+ if (raw.type === 'run') {
31
+ try {
32
+ const { mutant, tests } = raw;
33
+ const result = await runtime.run(mutant, tests);
34
+ process.send?.({
35
+ type: 'result',
36
+ killed: result.killed,
37
+ durationMs: result.durationMs,
38
+ error: result.error,
39
+ });
40
+ }
41
+ catch (err) {
42
+ process.send?.({
43
+ type: 'result',
44
+ killed: true,
45
+ durationMs: 0,
46
+ error: String(err),
47
+ });
48
+ }
49
+ }
50
+ });
51
+ }
52
+ main().catch((err) => {
53
+ log.error(`Fatal error: ${err}`);
54
+ process.exit(1);
55
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Mutation Testing Orchestrator
3
+ *
4
+ * Coordinates the mutation testing process:
5
+ * 1. Parse CLI arguments and load configuration
6
+ * 2. Discover targets and tests
7
+ * 3. Run baseline tests
8
+ * 4. Enumerate mutation variants
9
+ * 5. Execute mutants via worker pool
10
+ * 6. Report results
11
+ */
12
+ export { readMutantCache } from './cache.js';
13
+ export declare function runOrchestrator(cliArgs: string[], cwd: string): Promise<void>;
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Mutation Testing Orchestrator
3
+ *
4
+ * Coordinates the mutation testing process:
5
+ * 1. Parse CLI arguments and load configuration
6
+ * 2. Discover targets and tests
7
+ * 3. Run baseline tests
8
+ * 4. Enumerate mutation variants
9
+ * 5. Execute mutants via worker pool
10
+ * 6. Report results
11
+ */
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+ import { normalizePath } from 'vite';
15
+ import { render } from 'ink';
16
+ import { createElement } from 'react';
17
+ import { autoDiscoverTargetsAndTests } from './discover.js';
18
+ import { listChangedFiles } from './changed.js';
19
+ import { loadMutineerConfig } from './config.js';
20
+ import { Progress } from '../utils/progress.js';
21
+ import { computeSummary, printSummary } from '../utils/summary.js';
22
+ import { loadCoverageData, loadPerTestCoverageData, isLineCovered, } from '../utils/coverage.js';
23
+ import { createVitestAdapter, isCoverageRequestedInArgs, } from './vitest/index.js';
24
+ import { createJestAdapter } from './jest/index.js';
25
+ import { createLogger } from '../utils/logger.js';
26
+ import { PoolSpinner } from '../utils/PoolSpinner.js';
27
+ // CLI argument parsing
28
+ import { parseCliOptions } from './args.js';
29
+ // Cache management
30
+ import { clearCacheOnStart, saveCacheAtomic, readMutantCache, keyForTests, hash, } from './cache.js';
31
+ // Variant enumeration
32
+ import { enumerateVariantsForTarget, filterTestsByCoverage, getTargetFile, } from './variants.js';
33
+ const log = createLogger('orchestrator');
34
+ let testMap;
35
+ // Per-mutant test timeout (ms). Can be overridden with env MUTINEER_MUTANT_TIMEOUT_MS
36
+ const MUTANT_TIMEOUT_MS = (() => {
37
+ const raw = process.env.MUTINEER_MUTANT_TIMEOUT_MS;
38
+ const n = raw ? Number(raw) : NaN;
39
+ return Number.isFinite(n) && n > 0 ? n : 30_000;
40
+ })();
41
+ import { cleanupMutineerDirs } from './cleanup.js';
42
+ // Re-export readMutantCache for external use
43
+ export { readMutantCache } from './cache.js';
44
+ export async function runOrchestrator(cliArgs, cwd) {
45
+ // Load configuration
46
+ const configPath = cliArgs.find((arg, i) => arg === '--config' || arg === '-c'
47
+ ? cliArgs[i + 1]
48
+ : arg.startsWith('--config=') || arg.startsWith('-c='));
49
+ const cfgPath = configPath?.startsWith('--config=')
50
+ ? configPath.slice(9)
51
+ : configPath?.startsWith('-c=')
52
+ ? configPath.slice(3)
53
+ : cliArgs.includes('--config')
54
+ ? cliArgs[cliArgs.indexOf('--config') + 1]
55
+ : cliArgs.includes('-c')
56
+ ? cliArgs[cliArgs.indexOf('-c') + 1]
57
+ : undefined;
58
+ const cfg = await loadMutineerConfig(cwd, cfgPath);
59
+ // Parse CLI options
60
+ const opts = parseCliOptions(cliArgs, cfg);
61
+ await clearCacheOnStart(cwd);
62
+ // Create test runner adapter
63
+ const adapter = (opts.runner === 'jest' ? createJestAdapter : createVitestAdapter)({
64
+ cwd,
65
+ concurrency: opts.concurrency,
66
+ timeoutMs: MUTANT_TIMEOUT_MS,
67
+ config: cfg,
68
+ cliArgs,
69
+ });
70
+ // Detect coverage configuration from the adapter
71
+ const coverageConfig = await adapter.detectCoverageConfig();
72
+ const wantsPerTestCoverageFromConfig = coverageConfig.perTestEnabled;
73
+ const coveragePreference = cfg.coverage;
74
+ const wantsCoverageRun = coveragePreference === true
75
+ ? true
76
+ : coveragePreference === false
77
+ ? false
78
+ : isCoverageRequestedInArgs(cliArgs) || coverageConfig.coverageEnabled;
79
+ // Load pre-existing coverage data if provided
80
+ let coverageData = null;
81
+ let perTestCoverage = null;
82
+ if (opts.coverageFilePath) {
83
+ log.info(`Loading coverage data from ${opts.coverageFilePath}...`);
84
+ coverageData = await loadCoverageData(opts.coverageFilePath, cwd);
85
+ log.info(`Loaded coverage for ${coverageData.coveredLines.size} files`);
86
+ }
87
+ // If --only-covered-lines but no coverage file, we'll generate it during baseline
88
+ const needsCoverageFromBaseline = opts.wantsOnlyCoveredLines && !coverageData;
89
+ const hasCoverageProviderInstalled = adapter.hasCoverageProvider();
90
+ const rawPerTestCoverage = opts.wantsPerTestCoverage ||
91
+ wantsPerTestCoverageFromConfig ||
92
+ (opts.wantsOnlyCoveredLines && hasCoverageProviderInstalled);
93
+ const wantsPerTestCoverage = opts.runner === 'jest' ? false : rawPerTestCoverage;
94
+ if (opts.runner === 'jest' && rawPerTestCoverage) {
95
+ log.warn('Per-test coverage is not supported for Jest; continuing without per-test coverage.');
96
+ }
97
+ if (needsCoverageFromBaseline && !hasCoverageProviderInstalled) {
98
+ log.warn('The "onlyCoveredLines" option requires a coverage provider to generate coverage data.');
99
+ log.warn('Please install the appropriate coverage package (or disable onlyCoveredLines).');
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+ if (opts.wantsOnlyCoveredLines &&
104
+ coverageData &&
105
+ !hasCoverageProviderInstalled) {
106
+ log.warn('The "onlyCoveredLines" option is enabled, but no coverage provider is installed.');
107
+ log.warn('Running baseline tests without injecting per-test coverage; existing coverageFile will be used for filtering.');
108
+ }
109
+ log.info(`Mutineer starting in ${opts.wantsChangedWithDeps
110
+ ? 'changed files with dependencies'
111
+ : opts.wantsChanged
112
+ ? 'changed files only'
113
+ : 'full'} mode${opts.wantsOnlyCoveredLines ? ' (only covered lines)' : ''}...`);
114
+ log.info(`Using concurrency=${opts.concurrency} (cpus=${os.cpus().length})`);
115
+ const enableCoverageForBaseline = needsCoverageFromBaseline ||
116
+ wantsPerTestCoverage ||
117
+ wantsCoverageRun ||
118
+ (opts.wantsOnlyCoveredLines && hasCoverageProviderInstalled);
119
+ // Enumerate changed files if requested
120
+ const changedAbs = opts.wantsChanged || opts.wantsChangedWithDeps
121
+ ? new Set(listChangedFiles(cwd, {
122
+ includeDeps: opts.wantsChangedWithDeps,
123
+ baseRef: cfg.baseRef,
124
+ maxDepth: cfg.dependencyDepth,
125
+ }))
126
+ : null;
127
+ const variants = [];
128
+ const cache = await readMutantCache(cwd);
129
+ // Always run discovery to build testMap (maps source files → test files)
130
+ const discovered = await autoDiscoverTargetsAndTests(cwd, cfg);
131
+ testMap = discovered.testMap;
132
+ // Use explicit targets if provided, otherwise use discovered targets
133
+ const targets = cfg.targets?.length
134
+ ? [...cfg.targets]
135
+ : (cfg.autoDiscover ?? true)
136
+ ? discovered.targets
137
+ : [];
138
+ // Collect all test files for baseline run
139
+ const allTestFiles = new Set();
140
+ for (const target of targets) {
141
+ const file = getTargetFile(target);
142
+ const absFile = normalizePath(path.isAbsolute(file) ? file : path.join(cwd, file));
143
+ if (changedAbs && !changedAbs.has(absFile))
144
+ continue;
145
+ const testsAbs = testMap?.get(normalizePath(absFile));
146
+ if (testsAbs) {
147
+ for (const t of testsAbs)
148
+ allTestFiles.add(t);
149
+ }
150
+ }
151
+ const baselineTests = Array.from(allTestFiles);
152
+ if (!baselineTests.length) {
153
+ log.info('No tests found for targets. Exiting.');
154
+ return;
155
+ }
156
+ // Run baseline tests first (with coverage if needed for filtering)
157
+ log.info(`Running ${baselineTests.length} baseline tests${enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`);
158
+ const baselineOk = await adapter.runBaseline(baselineTests, {
159
+ collectCoverage: enableCoverageForBaseline ?? false,
160
+ perTestCoverage: wantsPerTestCoverage ?? false,
161
+ });
162
+ if (!baselineOk) {
163
+ process.exitCode = 1;
164
+ return;
165
+ }
166
+ log.info('\u2713 Baseline tests complete');
167
+ // Load coverage from baseline if we generated it
168
+ if (needsCoverageFromBaseline) {
169
+ const defaultCoveragePath = path.join(cwd, 'coverage', 'coverage-final.json');
170
+ log.info(`Loading coverage data from ${defaultCoveragePath}...`);
171
+ try {
172
+ coverageData = await loadCoverageData(defaultCoveragePath, cwd);
173
+ log.info(`Loaded coverage for ${coverageData.coveredLines.size} files`);
174
+ }
175
+ catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ log.warn(`Warning: Could not load coverage data: ${msg}`);
178
+ log.warn('Continuing without coverage filtering.');
179
+ }
180
+ }
181
+ // Load per-test coverage if requested
182
+ if (wantsPerTestCoverage) {
183
+ const reportsDir = path.join(cwd, 'coverage');
184
+ log.info('Loading per-test coverage data...');
185
+ perTestCoverage = await loadPerTestCoverageData(reportsDir, cwd);
186
+ if (!perTestCoverage) {
187
+ log.warn('Per-test coverage data not found. Continuing without per-test test pruning.');
188
+ }
189
+ else {
190
+ log.info(`Loaded per-test coverage for ${perTestCoverage.size} tests`);
191
+ }
192
+ }
193
+ // Enumerate variants for targets in parallel. Keep order deterministic by mapping then flattening.
194
+ const enumerated = await Promise.all(targets.map(async (target) => {
195
+ const file = getTargetFile(target);
196
+ const absFile = normalizePath(path.isAbsolute(file) ? file : path.join(cwd, file));
197
+ if (changedAbs && !changedAbs.has(absFile))
198
+ return [];
199
+ log.debug('Target file: ' + absFile);
200
+ const files = await enumerateVariantsForTarget(cwd, target, cfg.include, cfg.exclude, cfg.maxMutantsPerFile);
201
+ const testsAbs = testMap?.get(normalizePath(absFile));
202
+ const tests = testsAbs ? Array.from(testsAbs) : [];
203
+ log.debug(` found ${files.length} variants, linked to ${tests.length} tests`);
204
+ // Filter by coverage if enabled
205
+ let filtered = files;
206
+ if (coverageData) {
207
+ filtered = files.filter((v) => isLineCovered(coverageData, absFile, v.line));
208
+ if (filtered.length !== files.length) {
209
+ log.debug(` filtered ${files.length} -> ${filtered.length} variants by coverage`);
210
+ }
211
+ }
212
+ return filtered.map((v) => ({ ...v, tests }));
213
+ }));
214
+ for (const list of enumerated)
215
+ variants.push(...list);
216
+ if (!variants.length) {
217
+ const msg = coverageData
218
+ ? 'No mutants to test (all mutations are on uncovered lines). Exiting.'
219
+ : 'No mutants to test. Exiting.';
220
+ log.info(msg);
221
+ return;
222
+ }
223
+ const progress = new Progress(variants.length, {
224
+ mode: opts.progressMode === 'bar' ? 'bar' : 'list',
225
+ stream: 'stderr',
226
+ });
227
+ // Track mutation testing duration
228
+ const mutationStartTime = Date.now();
229
+ // Precompute task metadata for faster worker loops (sort tests, compute keys once)
230
+ const tasks = variants.map((v) => {
231
+ let tests = Array.from(v.tests);
232
+ if (perTestCoverage && tests.length) {
233
+ const before = tests.length;
234
+ tests = filterTestsByCoverage(perTestCoverage, tests, v.file, v.line);
235
+ if (tests.length !== before) {
236
+ log.debug(`Pruned tests ${before} -> ${tests.length} for mutant ${v.name} via per-test coverage`);
237
+ }
238
+ }
239
+ tests.sort();
240
+ const testSig = hash(keyForTests(tests));
241
+ const codeSig = hash(v.code);
242
+ const key = `${testSig}:${codeSig}`;
243
+ return { v, tests, key };
244
+ });
245
+ const workerCount = Math.max(1, Math.min(opts.concurrency, tasks.length));
246
+ // Ensure we only finish once
247
+ let finished = false;
248
+ const finishOnce = () => {
249
+ if (finished)
250
+ return;
251
+ finished = true;
252
+ const durationMs = Date.now() - mutationStartTime;
253
+ // Finish progress display first
254
+ progress.finish();
255
+ // Compute and print a human-friendly summary
256
+ const summary = computeSummary(cache);
257
+ printSummary(summary, cache, durationMs);
258
+ if (opts.minKillPercent !== undefined) {
259
+ const killRateString = summary.killRate.toFixed(2);
260
+ const thresholdString = opts.minKillPercent.toFixed(2);
261
+ if (summary.killRate < opts.minKillPercent) {
262
+ const note = summary.evaluated === 0 ? ' No mutants were executed.' : '';
263
+ log.error(`Mutation kill rate ${killRateString}% did not meet required ${thresholdString}% threshold.${note}`);
264
+ // Set exit code and let caller/CLI decide if it should terminate abruptly
265
+ process.exitCode = 1;
266
+ }
267
+ else {
268
+ log.info(`Mutation kill rate ${killRateString}% meets required ${thresholdString}% threshold`);
269
+ }
270
+ }
271
+ };
272
+ // Initialize test runner adapter
273
+ const workerLogSuffix = workerCount < opts.concurrency ? ` (requested ${opts.concurrency})` : '';
274
+ log.info(`Initializing ${adapter.name} worker pool with ${workerCount} workers...${workerLogSuffix}`);
275
+ const poolStart = Date.now();
276
+ // Ink spinner on stderr while workers start up
277
+ let spinnerInstance = null;
278
+ if (process.stderr.isTTY) {
279
+ spinnerInstance = render(createElement(PoolSpinner, { message: 'Starting pool...' }), { stdout: process.stderr, stderr: process.stderr });
280
+ }
281
+ try {
282
+ await adapter.init(workerCount);
283
+ }
284
+ finally {
285
+ if (spinnerInstance) {
286
+ spinnerInstance.unmount();
287
+ spinnerInstance = null;
288
+ }
289
+ }
290
+ const poolDurationMs = Date.now() - poolStart;
291
+ log.info(`\u2713 Worker pool ready (${poolDurationMs}ms)`);
292
+ progress.start();
293
+ let nextIdx = 0;
294
+ /**
295
+ * Process a single mutant task: check cache, run tests if needed, update cache and progress.
296
+ * This function is designed to be called by multiple workers concurrently.
297
+ */
298
+ async function processTask(task) {
299
+ const { v, tests, key } = task;
300
+ log.debug('Cache ' + JSON.stringify(cache));
301
+ // Check if already cached
302
+ const cached = cache[key];
303
+ if (cached) {
304
+ progress.update(cached.status);
305
+ return;
306
+ }
307
+ // Skip if no tests import this file
308
+ if (tests.length === 0) {
309
+ cache[key] = {
310
+ status: 'skipped',
311
+ file: v.file,
312
+ line: v.line,
313
+ col: v.col,
314
+ mutator: v.name,
315
+ };
316
+ progress.update('skipped');
317
+ return;
318
+ }
319
+ log.debug(`Running tests for mutant ${v.name} ${JSON.stringify(tests)}`);
320
+ // Run mutant via test runner adapter
321
+ const result = await adapter.runMutant({
322
+ id: v.id,
323
+ name: v.name,
324
+ file: v.file,
325
+ code: v.code,
326
+ line: v.line,
327
+ col: v.col,
328
+ }, tests);
329
+ const status = result.status;
330
+ cache[key] = {
331
+ status,
332
+ file: v.file,
333
+ line: v.line,
334
+ col: v.col,
335
+ mutator: v.name,
336
+ };
337
+ progress.update(status);
338
+ }
339
+ /**
340
+ * Worker coroutine: process mutant tasks from the queue until exhausted.
341
+ * Multiple workers run concurrently, sharing the task queue.
342
+ */
343
+ async function worker() {
344
+ while (true) {
345
+ const i = nextIdx++;
346
+ if (i >= tasks.length)
347
+ break;
348
+ await processTask(tasks[i]);
349
+ }
350
+ }
351
+ const workers = [];
352
+ // Register signal handlers so Ctrl+C / SIGTERM still triggers cleanup
353
+ let signalCleanedUp = false;
354
+ const signalHandler = async (signal) => {
355
+ if (signalCleanedUp)
356
+ return;
357
+ signalCleanedUp = true;
358
+ log.info(`\nReceived ${signal}, cleaning up...`);
359
+ finishOnce();
360
+ await adapter.shutdown();
361
+ await cleanupMutineerDirs(cwd);
362
+ process.exit(1);
363
+ };
364
+ process.on('SIGINT', () => void signalHandler('SIGINT'));
365
+ process.on('SIGTERM', () => void signalHandler('SIGTERM'));
366
+ try {
367
+ // Spawn worker coroutines
368
+ for (let i = 0; i < workerCount; i++)
369
+ workers.push(worker());
370
+ // Wait for all workers to complete
371
+ await Promise.all(workers);
372
+ // Persist results to disk
373
+ await saveCacheAtomic(cwd, cache);
374
+ }
375
+ finally {
376
+ // Remove signal handlers to avoid double cleanup
377
+ process.removeAllListeners('SIGINT');
378
+ process.removeAllListeners('SIGTERM');
379
+ if (!signalCleanedUp) {
380
+ finishOnce();
381
+ // Shutdown adapter
382
+ await adapter.shutdown();
383
+ // Clean up all __mutineer__ temp directories
384
+ await cleanupMutineerDirs(cwd);
385
+ }
386
+ }
387
+ }
@@ -0,0 +1 @@
1
+ export {};