oxmutate 0.0.0-stage → 1.0.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/dist/config.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Options, read from `stryker.config.*` as it stands (migration is a no-op: the file Stryker reads
3
+ * is the file oxmutate reads) and from `oxmutate.config.*`, which wins where both set a key.
4
+ * Every key Stryker documents that oxmutate does not use is reported, never silently dropped
5
+ * (spec R15).
6
+ */
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { availableParallelism } from 'node:os';
9
+ import path from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+ import {} from './native.js';
12
+ import { resolveEsm } from './resolve.js';
13
+ import {} from './spec-oracle.js';
14
+ const NOT_NEEDED = {
15
+ inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
16
+ tempDirName: 'no sandbox directory exists',
17
+ cleanTempDir: 'no sandbox directory exists',
18
+ symlinkNodeModules: 'no sandbox directory exists',
19
+ plugins: 'no runtime plugin loading; operators and ignorers are data',
20
+ appendPlugins: 'no runtime plugin loading',
21
+ packageManager: 'oxmutate never installs anything',
22
+ coverageAnalysis: 'always perTest',
23
+ maxTestRunnerReuse: 'workers are replaced on pollution and runaways, automatically',
24
+ dashboard: 'the Stryker Dashboard reporter is M4 work',
25
+ clearTextReporter: 'the summary is always printed',
26
+ eventReporter: 'not implemented',
27
+ logLevel: 'not implemented',
28
+ fileLogLevel: 'not implemented',
29
+ allowConsoleColors: 'colour follows the terminal (roundel policy)',
30
+ disableTypeChecks: 'always on: instrumented files carry // @ts-nocheck, and the checker reads the original sources',
31
+ ignorers: 'ignorer plugins are code; oxmutate takes the same rules as data in `ignore` (see the README)',
32
+ disableBail: 'oxmutate always stops at the first killing test',
33
+ testFiles: 'test files come from the Vitest config',
34
+ ignorePatterns: 'oxmutate never copies the project, so there is nothing to ignore',
35
+ force: 'use --no-incremental',
36
+ checkerNodeArgs: 'not implemented',
37
+ };
38
+ /**
39
+ * Defaults where the config sets nothing. Stryker's, except the per-mutant slack: Stryker's
40
+ * `timeoutMS` is 5000, and a warm run needs far less (cli.ts `--mutant-timeout`).
41
+ */
42
+ const MAX_DEFAULT_CONCURRENCY = 8;
43
+ const DEFAULT_TIMEOUT_MS = 500;
44
+ const DEFAULT_TIMEOUT_FACTOR = 1.5;
45
+ const DEFAULT_THRESHOLD_HIGH = 80;
46
+ const DEFAULT_THRESHOLD_LOW = 60;
47
+ const DEFAULT_DRY_RUN_MINUTES = 5;
48
+ const USED = new Set(['mutate', 'vitest', 'concurrency', 'timeoutMS', 'timeoutFactor', 'thresholds', 'ignoreStatic', 'reporters', 'jsonReporter', 'htmlReporter', 'incremental', 'incrementalFile', '$schema', 'mutator', 'dryRunOnly', 'dryRunTimeoutMinutes', 'buildCommand', 'testRunnerNodeArgs', 'allowEmpty', 'warnings', 'testRunner', 'commandRunner', 'checkers', 'tsconfigFile']);
49
+ async function loadFile(file) {
50
+ if (file.endsWith('.json')) {
51
+ // Stryker disable next-line StringLiteral: without an encoding readFileSync returns a Buffer, which JSON.parse reads as UTF-8 text all the same
52
+ return JSON.parse(readFileSync(file, 'utf8'));
53
+ }
54
+ const mod = (await import(pathToFileURL(file).href));
55
+ const value = mod.default ?? mod;
56
+ return (typeof value === 'function' ? await value() : value);
57
+ }
58
+ function find(root, base) {
59
+ for (const ext of ['.mjs', '.js', '.cjs', '.json', '.mts', '.ts']) {
60
+ const f = path.join(root, `${base}.config${ext}`);
61
+ if (existsSync(f))
62
+ return f;
63
+ }
64
+ return undefined;
65
+ }
66
+ /** The Vite (and Vite+) config files, in the order Vite and Vitest look for them. */
67
+ const VITE_CONFIGS = ['vite.config', 'vitest.config'].flatMap((base) => ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].map((ext) => `${base}${ext}`));
68
+ /**
69
+ * The `mutate` block of the project's Vite config — the Vite+ shape, one file for test, lint, fmt
70
+ * and mutate. Loaded with the project's own Vite (`loadConfigFromFile`), so a TypeScript config and
71
+ * its imports work as Vite sees them. Inside the block the file list is `include`, as Vitest names
72
+ * it; Stryker's `mutate` is accepted too. No Vite installed, or no block: nothing.
73
+ */
74
+ async function fromViteConfig(root) {
75
+ const file = VITE_CONFIGS.map((f) => path.join(root, f)).find((f) => existsSync(f));
76
+ // Stryker disable next-line ConditionalExpression: without the guard Vite searches the root for the same names, finds none, and returns null, which returns undefined below
77
+ if (!file)
78
+ return undefined;
79
+ let vite;
80
+ try {
81
+ vite = (await import(resolveEsm('vite', root)));
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ // Stryker disable next-line StringLiteral: the log level only decides whether Vite prints, and a config that loads prints nothing at any level
87
+ const level = 'silent';
88
+ const loaded = await vite.loadConfigFromFile({ command: 'serve', mode: 'test' }, file, root, level);
89
+ // Stryker disable next-line OptionalChaining: given a file that exists, Vite returns a config or throws; null is its answer only when it found no file
90
+ const block = loaded?.config['mutate'];
91
+ if (!block || typeof block !== 'object')
92
+ return undefined;
93
+ // `include` is the Vitest spelling; Stryker's `mutate`, when given too, is spread over it.
94
+ const { include, ...rest } = block;
95
+ return { file, block: { mutate: include, ...rest } };
96
+ }
97
+ export async function loadOptions(root, overrides = {}, configFile) {
98
+ const strykerFile = find(root, 'stryker');
99
+ const oxFile = configFile ? path.resolve(root, configFile) : find(root, 'oxmutate');
100
+ const stryker = strykerFile ? await loadFile(strykerFile) : {};
101
+ const vite = oxFile ? undefined : await fromViteConfig(root);
102
+ const ox = oxFile ? await loadFile(oxFile) : (vite?.block ?? {});
103
+ const raw = { ...stryker, ...ox };
104
+ const unused = [];
105
+ for (const key of Object.keys(stryker)) {
106
+ if (USED.has(key))
107
+ continue;
108
+ unused.push({ key, why: NOT_NEEDED[key] ?? 'not a Stryker 10.0.0 option oxmutate knows' });
109
+ }
110
+ const thresholds = (raw['thresholds'] ?? {});
111
+ const vitest = (raw['vitest'] ?? {});
112
+ const json = (raw['jsonReporter'] ?? {});
113
+ const html = (raw['htmlReporter'] ?? {});
114
+ const mutator = (raw['mutator'] ?? {});
115
+ const mutate = raw['mutate'] ?? ['src/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}', '!src/**/*.{test,spec}.*', '!src/**/*.d.ts'];
116
+ return {
117
+ root,
118
+ mutate,
119
+ vitestConfig: vitest.configFile ? path.resolve(root, vitest.configFile) : undefined,
120
+ concurrency: Number(raw['concurrency'] ?? Math.max(1, Math.min(MAX_DEFAULT_CONCURRENCY, availableParallelism() - 1))),
121
+ timeoutMS: Number(raw['timeoutMS'] ?? DEFAULT_TIMEOUT_MS),
122
+ timeoutFactor: Number(raw['timeoutFactor'] ?? DEFAULT_TIMEOUT_FACTOR),
123
+ thresholds: { high: thresholds.high ?? DEFAULT_THRESHOLD_HIGH, low: thresholds.low ?? DEFAULT_THRESHOLD_LOW, break: thresholds.break ?? null },
124
+ excludedMutations: mutator.excludedMutations ?? [],
125
+ ignoreStatic: raw['ignoreStatic'] === true,
126
+ strykerCompat: raw['strykerCompat'] === true,
127
+ reporters: raw['reporters'] ?? ['clear-text', 'json'],
128
+ jsonReport: path.resolve(root, json.fileName ?? 'reports/mutation/mutation.json'),
129
+ sarifReport: path.resolve(root, raw['sarifReport'] ?? 'reports/mutation/mutation.sarif'),
130
+ htmlReport: path.resolve(root, html.fileName ?? 'reports/mutation/mutation.html'),
131
+ incremental: raw['incremental'] === true,
132
+ incrementalFile: path.resolve(root, raw['incrementalFile'] ?? 'reports/oxmutate-incremental.json'),
133
+ flakyReruns: Number(raw['flakyReruns'] ?? 2),
134
+ unused,
135
+ configFile: oxFile ?? vite?.file ?? strykerFile,
136
+ dryRunOnly: raw['dryRunOnly'] === true,
137
+ dryRunTimeoutMinutes: Number(raw['dryRunTimeoutMinutes'] ?? DEFAULT_DRY_RUN_MINUTES),
138
+ buildCommand: typeof raw['buildCommand'] === 'string' ? raw['buildCommand'] : undefined,
139
+ testRunnerNodeArgs: raw['testRunnerNodeArgs'] ?? [],
140
+ allowEmpty: raw['allowEmpty'] === true,
141
+ specOracles: raw['specOracles'] ?? [],
142
+ ignore: raw['ignore'] ?? [],
143
+ // Stryker disable next-line ArrayDeclaration: a placeholder element is not 'typescript' either, so includes() is false as for an empty list
144
+ typeCheck: (raw['checkers'] ?? []).includes('typescript'),
145
+ tsconfigFile: raw['tsconfigFile'] ?? 'tsconfig.json',
146
+ typeChecker: typeof raw['typeChecker'] === 'string' ? raw['typeChecker'] : undefined,
147
+ commandRunner: raw['testRunner'] === 'command' ? (raw['commandRunner']?.command ?? 'npm test') : undefined,
148
+ warnUnused: raw['warnings'] !== false && raw['warnings']?.unknownOptions !== false,
149
+ ...overrides,
150
+ };
151
+ }
152
+ // One `\s` after next-line is enough: the list's class takes any further whitespace, and is trimmed.
153
+ const DIRECTIVE = /\/\/\s*(?:Stryker|oxmutate)\s+disable\s+next-line\s([\w,\s]+?|all)\s*:\s*(.*)/i;
154
+ const NO_REASON = /\/\/\s*(?:Stryker|oxmutate)\s+disable\s+next-line\s([\w,\s]+?|all)\s*$/i;
155
+ /** Disable directives in a file. A directive without a reason is an error (burgee's own rule). */
156
+ export function disables(source, file) {
157
+ const out = [];
158
+ const errors = [];
159
+ source.split('\n').forEach((line, i) => {
160
+ const m = DIRECTIVE.exec(line);
161
+ if (m) {
162
+ // Stryker disable next-line StringLiteral: both groups take part in every match, so the fallback is never used; it is there for the type
163
+ const [list, reason] = [(m[1] ?? '').trim(), (m[2] ?? '').trim()];
164
+ out.push({ line: i + 2, mutators: list.toLowerCase() === 'all' ? 'all' : list.split(',').map((s) => s.trim()).filter(Boolean), reason });
165
+ }
166
+ else if (NO_REASON.test(line)) {
167
+ errors.push(`${file}:${String(i + 1)}: disable comment without a reason. fix: add ": <why this mutant cannot be killed>"`);
168
+ }
169
+ });
170
+ return { disables: out, errors };
171
+ }
172
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,83 @@
1
+ import { type Options } from './config.js';
2
+ import type { FrozenOracle } from './frozen.js';
3
+ import { type NativeMutant } from './native.js';
4
+ import { type SpecSummary } from './spec-oracle.js';
5
+ export type Status = 'Killed' | 'Survived' | 'Timeout' | 'NoCoverage' | 'RuntimeError' | 'CompileError' | 'Ignored';
6
+ export interface MutantResult extends NativeMutant {
7
+ file: string;
8
+ status: Status;
9
+ statusReason?: string | undefined;
10
+ coveredBy: string[];
11
+ /** A spec oracle's replay tests that reach this mutant (spec-oracle.ts); not in `coveredBy`. */
12
+ specCoveredBy?: string[] | undefined;
13
+ /** The spec oracle's verdict, beside the tests' `status`. */
14
+ spec?: {
15
+ oracle: string;
16
+ status: 'Killed' | 'Survived' | 'Unknown';
17
+ killedBy?: string[] | undefined;
18
+ } | undefined;
19
+ killedBy?: string[] | undefined;
20
+ testsCompleted?: number | undefined;
21
+ static?: boolean | undefined;
22
+ /** The verdict changed between re-runs: the covering tests are nondeterministic. */
23
+ flaky?: boolean | undefined;
24
+ /** Reused from the incremental cache, not run. */
25
+ reused?: boolean | undefined;
26
+ }
27
+ export interface TestInfo {
28
+ id: string;
29
+ file: string;
30
+ name: string;
31
+ ms: number;
32
+ }
33
+ export interface RunStats {
34
+ files: number;
35
+ mutants: number;
36
+ instrumentMs: number;
37
+ dryRunMs: number;
38
+ mutationMs: number;
39
+ isolatedMs: number;
40
+ wallMs: number;
41
+ testExecutions: number;
42
+ watchdogKills: number;
43
+ crashes: number;
44
+ canaryFiles: number;
45
+ /** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
46
+ pollutionRestarts: number;
47
+ /** Survivors of the warm run that a whole-file re-run killed (cross-test effects). */
48
+ survivorsOverturned: number;
49
+ /** Watchdog timeouts that finished when re-run with 3x the budget: the machine was slow, not the mutant. */
50
+ timeoutsOverturned: number;
51
+ /** Survivors the type checker refused (checker.ts): CompileError, out of the score. */
52
+ compileErrors: number;
53
+ isolatedRuns: number;
54
+ reused: number;
55
+ flakyTests: number;
56
+ flakyMutants: number;
57
+ units: number;
58
+ serversSpawned: number;
59
+ }
60
+ export interface RunResult {
61
+ root: string;
62
+ options: Options;
63
+ sources: Record<string, string>;
64
+ mutants: MutantResult[];
65
+ tests: TestInfo[];
66
+ stats: RunStats;
67
+ score: {
68
+ detected: number;
69
+ undetected: number;
70
+ total: number;
71
+ mutationScore: number;
72
+ covered: number;
73
+ };
74
+ /** Each spec oracle's F metrics (spec-oracle.ts). */
75
+ spec?: SpecSummary[] | undefined;
76
+ /** The frozen oracle's comparison, when `frozenOracle` was set (frozen.ts). */
77
+ frozen?: FrozenOracle | undefined;
78
+ /** Set when the run stopped after the dry run (`dryRunOnly`): no verdicts, no reports. */
79
+ dryRunOnly?: boolean | undefined;
80
+ }
81
+ export declare class InstrumentFault extends Error {
82
+ }
83
+ export declare function run(options: Options): Promise<RunResult>;