@mintwork/honest-tests 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/CHANGELOG.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +280 -2
  6. package/commands/audit.md +17 -0
  7. package/commands/mutate.md +10 -0
  8. package/dist/adapters/index.d.ts +3 -0
  9. package/dist/adapters/index.js +8 -0
  10. package/dist/adapters/js/ast.d.ts +24 -0
  11. package/dist/adapters/js/ast.js +199 -0
  12. package/dist/adapters/js/index.d.ts +25 -0
  13. package/dist/adapters/js/index.js +122 -0
  14. package/dist/adapters/js/mutants.d.ts +8 -0
  15. package/dist/adapters/js/mutants.js +190 -0
  16. package/dist/adapters/js/parse-tests.d.ts +5 -0
  17. package/dist/adapters/js/parse-tests.js +1101 -0
  18. package/dist/adapters/python/index.d.ts +17 -0
  19. package/dist/adapters/python/index.js +26 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +21 -0
  22. package/dist/core/engine.d.ts +44 -0
  23. package/dist/core/engine.js +205 -0
  24. package/dist/core/types.d.ts +195 -0
  25. package/dist/core/types.js +7 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.js +5 -0
  28. package/dist/mutate/runner.d.ts +59 -0
  29. package/dist/mutate/runner.js +379 -0
  30. package/dist/report/colors.d.ts +11 -0
  31. package/dist/report/colors.js +13 -0
  32. package/dist/report/human.d.ts +6 -0
  33. package/dist/report/human.js +79 -0
  34. package/dist/report/json.d.ts +4 -0
  35. package/dist/report/json.js +8 -0
  36. package/dist/rules/asserts-on-mock.d.ts +2 -0
  37. package/dist/rules/asserts-on-mock.js +53 -0
  38. package/dist/rules/asserts-on-own-literal.d.ts +2 -0
  39. package/dist/rules/asserts-on-own-literal.js +36 -0
  40. package/dist/rules/catch-only-assertion.d.ts +2 -0
  41. package/dist/rules/catch-only-assertion.js +22 -0
  42. package/dist/rules/conditional-assertion.d.ts +2 -0
  43. package/dist/rules/conditional-assertion.js +30 -0
  44. package/dist/rules/excessive-mocking.d.ts +3 -0
  45. package/dist/rules/excessive-mocking.js +22 -0
  46. package/dist/rules/focused-test.d.ts +2 -0
  47. package/dist/rules/focused-test.js +15 -0
  48. package/dist/rules/helpers.d.ts +5 -0
  49. package/dist/rules/helpers.js +18 -0
  50. package/dist/rules/index.d.ts +3 -0
  51. package/dist/rules/index.js +31 -0
  52. package/dist/rules/mocked-unit-under-test.d.ts +2 -0
  53. package/dist/rules/mocked-unit-under-test.js +32 -0
  54. package/dist/rules/never-failing-assertion.d.ts +2 -0
  55. package/dist/rules/never-failing-assertion.js +43 -0
  56. package/dist/rules/no-assertions.d.ts +2 -0
  57. package/dist/rules/no-assertions.js +16 -0
  58. package/dist/rules/skipped-test.d.ts +2 -0
  59. package/dist/rules/skipped-test.js +15 -0
  60. package/dist/rules/snapshot-only.d.ts +2 -0
  61. package/dist/rules/snapshot-only.js +18 -0
  62. package/dist/rules/swallowed-assertion.d.ts +2 -0
  63. package/dist/rules/swallowed-assertion.js +18 -0
  64. package/dist/rules/tautological-assertion.d.ts +2 -0
  65. package/dist/rules/tautological-assertion.js +42 -0
  66. package/dist/run.d.ts +12 -0
  67. package/dist/run.js +204 -0
  68. package/integrations/codex/AGENTS.md +20 -0
  69. package/integrations/cursor/honest-tests.mdc +24 -0
  70. package/package.json +73 -4
  71. package/skills/honest-tests/SKILL.md +141 -0
  72. package/skills/honest-tests/references/audit-procedure.md +128 -0
  73. package/skills/honest-tests/references/mutation-testing.md +68 -0
  74. package/skills/honest-tests/references/test-smells.md +268 -0
  75. package/skills/honest-tests/references/writing-honest-tests.md +96 -0
@@ -0,0 +1,379 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { delimiter, dirname, extname, join, relative, resolve, sep } from 'node:path';
5
+ import { adapterFor } from '../adapters/index.js';
6
+ import { resolveTestScript, scopedTestCommand } from '../adapters/js/index.js';
7
+ import { DEFAULT_IGNORED_DIRS, discoverTestFiles } from '../core/engine.js';
8
+ export class MutationError extends Error {
9
+ output;
10
+ constructor(message, output = '') {
11
+ super(message);
12
+ this.output = output;
13
+ }
14
+ }
15
+ function findUp(start, name) {
16
+ let dir = start;
17
+ for (;;) {
18
+ const candidate = join(dir, name);
19
+ if (existsSync(candidate))
20
+ return candidate;
21
+ const parent = dirname(dir);
22
+ if (parent === dir)
23
+ return null;
24
+ dir = parent;
25
+ }
26
+ }
27
+ /** Nearest node_modules that holds installed packages (not just a `.vite` cache dir). */
28
+ function findNodeModules(start) {
29
+ let dir = start;
30
+ for (;;) {
31
+ const candidate = join(dir, 'node_modules');
32
+ if (existsSync(candidate)) {
33
+ const entries = readdirSync(candidate).filter((e) => e !== '.vite' && e !== '.cache' && e !== '.vite-temp');
34
+ if (entries.length > 0)
35
+ return candidate;
36
+ }
37
+ const parent = dirname(dir);
38
+ if (parent === dir)
39
+ return null;
40
+ dir = parent;
41
+ }
42
+ }
43
+ /** Every `node_modules/.bin` from `start` upward, so `vitest`/`jest` resolve like they do under npm. */
44
+ function binPaths(start) {
45
+ const out = [];
46
+ let dir = start;
47
+ for (;;) {
48
+ const bin = join(dir, 'node_modules', '.bin');
49
+ if (existsSync(bin))
50
+ out.push(bin);
51
+ const parent = dirname(dir);
52
+ if (parent === dir)
53
+ return out;
54
+ dir = parent;
55
+ }
56
+ }
57
+ function sourceFiles(dir, exts, out = []) {
58
+ let entries;
59
+ try {
60
+ entries = readdirSync(dir, { withFileTypes: true });
61
+ }
62
+ catch {
63
+ return out;
64
+ }
65
+ for (const e of entries) {
66
+ const p = join(dir, e.name);
67
+ if (e.isDirectory()) {
68
+ if (!DEFAULT_IGNORED_DIRS.has(e.name))
69
+ sourceFiles(p, exts, out);
70
+ }
71
+ else if (e.isFile() && exts.includes(extname(e.name).toLowerCase()))
72
+ out.push(p);
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * Test files that import `target` directly or through other local modules
78
+ * (`test -> Page.jsx -> lib/format.js`). Direct-import-only selection misses
79
+ * tests that exercise the target through its callers, which shows up as
80
+ * false survivors.
81
+ */
82
+ export function findRelatedTests(root, target, adapter) {
83
+ if (!adapter.localImports || !adapter.moduleId) {
84
+ return discoverTestFiles([root], root).filter((f) => adapter.testImportsTarget?.(f, readFileSync(f, 'utf8'), target));
85
+ }
86
+ const importers = new Map();
87
+ for (const file of sourceFiles(root, adapter.extensions)) {
88
+ let src;
89
+ try {
90
+ src = readFileSync(file, 'utf8');
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ for (const id of new Set(adapter.localImports(file, src)))
96
+ importers.set(id, [...(importers.get(id) ?? []), file]);
97
+ }
98
+ const tests = new Set();
99
+ const seen = new Set([adapter.moduleId(target)]);
100
+ const queue = [...seen];
101
+ while (queue.length) {
102
+ for (const file of importers.get(queue.shift()) ?? []) {
103
+ if (adapter.isTestFile(toPosix(file))) {
104
+ tests.add(file);
105
+ continue;
106
+ }
107
+ const id = adapter.moduleId(file);
108
+ if (!seen.has(id)) {
109
+ seen.add(id);
110
+ queue.push(id);
111
+ }
112
+ }
113
+ }
114
+ return [...tests].sort();
115
+ }
116
+ /** Nearest ancestor (or `root` itself) that is a pnpm/yarn/npm workspace root. */
117
+ function workspaceRootOf(root) {
118
+ let dir = root;
119
+ for (;;) {
120
+ if (existsSync(join(dir, 'pnpm-workspace.yaml')))
121
+ return dir;
122
+ try {
123
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
124
+ if (pkg.workspaces)
125
+ return dir;
126
+ }
127
+ catch {
128
+ /* no package.json here */
129
+ }
130
+ const parent = dirname(dir);
131
+ if (parent === dir)
132
+ return null;
133
+ dir = parent;
134
+ }
135
+ }
136
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
137
+ function copyProject(from, to) {
138
+ cpSync(from, to, {
139
+ recursive: true,
140
+ filter: (src) => {
141
+ const rel = relative(from, src);
142
+ return !rel.split(sep).includes('node_modules') && rel.split(sep)[0] !== '.git';
143
+ },
144
+ });
145
+ }
146
+ /** Every node_modules directory under `dir` (not descending into them). */
147
+ function nodeModulesDirs(dir, out = []) {
148
+ let entries;
149
+ try {
150
+ entries = readdirSync(dir, { withFileTypes: true });
151
+ }
152
+ catch {
153
+ return out;
154
+ }
155
+ for (const e of entries) {
156
+ if (!e.isDirectory() || e.name === '.git')
157
+ continue;
158
+ const p = join(dir, e.name);
159
+ if (e.name === 'node_modules')
160
+ out.push(p);
161
+ else
162
+ nodeModulesDirs(p, out);
163
+ }
164
+ return out;
165
+ }
166
+ /**
167
+ * Copy the project into `tmp` and return the directory to run tests in.
168
+ *
169
+ * Plain packages: `tmp/` is the copy and `tmp/node_modules` links to the nearest installed node_modules.
170
+ * Workspace packages (`pkgs/core` in a pnpm/yarn/npm monorepo): the whole workspace is copied (without
171
+ * node_modules and .git) so root configs, tsconfig project references and sibling packages resolve,
172
+ * and every node_modules directory is linked back at the same relative spot.
173
+ */
174
+ function prepareCopy(root, tmp) {
175
+ const ws = workspaceRootOf(root);
176
+ if (!ws || ws === root) {
177
+ copyProject(root, tmp);
178
+ const nm = findNodeModules(root);
179
+ if (nm)
180
+ symlinkSync(nm, join(tmp, 'node_modules'), linkType);
181
+ return tmp;
182
+ }
183
+ copyProject(ws, tmp);
184
+ for (const nm of nodeModulesDirs(ws)) {
185
+ const dest = join(tmp, relative(ws, nm));
186
+ if (existsSync(dest))
187
+ continue;
188
+ mkdirSync(dirname(dest), { recursive: true });
189
+ symlinkSync(nm, dest, linkType);
190
+ }
191
+ return join(tmp, relative(ws, root));
192
+ }
193
+ function toPosix(p) {
194
+ return p.split(sep).join('/');
195
+ }
196
+ function cleanEnv(extraPath) {
197
+ const env = {};
198
+ for (const [k, v] of Object.entries(process.env)) {
199
+ // Don't leak an outer vitest/jest worker context into the child run.
200
+ if (/^(VITEST|JEST_WORKER_ID|TINYPOOL|__VITEST)/.test(k))
201
+ continue;
202
+ env[k] = v;
203
+ }
204
+ const pathKey = Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH';
205
+ env[pathKey] = [...extraPath, env[pathKey] ?? ''].join(delimiter);
206
+ env.FORCE_COLOR = '0';
207
+ env.NO_COLOR = '1';
208
+ return env;
209
+ }
210
+ /** Test runs in flight. They run in their own process group, so Ctrl-C doesn't reach them by itself. */
211
+ const running = new Set();
212
+ function killTree(child) {
213
+ try {
214
+ if (child.pid && process.platform !== 'win32')
215
+ process.kill(-child.pid, 'SIGKILL');
216
+ else
217
+ child.kill('SIGKILL');
218
+ }
219
+ catch {
220
+ /* already gone */
221
+ }
222
+ }
223
+ /** Kill every in-flight test run (and its children). Used on Ctrl-C/SIGTERM before removing the copy. */
224
+ export function killRunningTests() {
225
+ for (const child of running)
226
+ killTree(child);
227
+ }
228
+ function runCommand(command, cwd, timeoutMs, extraPath) {
229
+ return new Promise((resolvePromise) => {
230
+ const started = Date.now();
231
+ const child = spawn(command, {
232
+ cwd,
233
+ shell: true,
234
+ env: cleanEnv(extraPath),
235
+ detached: process.platform !== 'win32',
236
+ stdio: ['ignore', 'pipe', 'pipe'],
237
+ });
238
+ running.add(child);
239
+ child.on('exit', () => running.delete(child));
240
+ // Keep the start (where runners print the actual error) and the end (the summary).
241
+ let head = '';
242
+ let tail = '';
243
+ let dropped = false;
244
+ const collect = (d) => {
245
+ const text = d.toString();
246
+ if (head.length < 6_000) {
247
+ const room = 6_000 - head.length;
248
+ head += text.slice(0, room);
249
+ tail += text.slice(room);
250
+ }
251
+ else
252
+ tail += text;
253
+ if (tail.length > 16_000) {
254
+ tail = tail.slice(-16_000);
255
+ dropped = true;
256
+ }
257
+ };
258
+ const output = () => (dropped ? `${head}\n…\n${tail}` : head + tail);
259
+ child.stdout?.on('data', collect);
260
+ child.stderr?.on('data', collect);
261
+ let timedOut = false;
262
+ const kill = () => killTree(child);
263
+ const timer = setTimeout(() => {
264
+ timedOut = true;
265
+ kill();
266
+ }, timeoutMs);
267
+ child.on('close', (code) => {
268
+ clearTimeout(timer);
269
+ resolvePromise({ code, timedOut, output: output(), durationMs: Date.now() - started });
270
+ });
271
+ child.on('error', (err) => {
272
+ clearTimeout(timer);
273
+ resolvePromise({ code: 1, timedOut, output: output() + String(err), durationMs: Date.now() - started });
274
+ });
275
+ });
276
+ }
277
+ function shellQuote(s) {
278
+ return /^[\w./@:-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
279
+ }
280
+ /**
281
+ * Mutation-test one source file.
282
+ *
283
+ * Safety: the project is copied to a fresh temp directory (node_modules is
284
+ * symlinked, never copied or written by us). Mutants are written only inside
285
+ * the copy, restored after every run, and the copy is deleted at the end —
286
+ * including on Ctrl-C. Your working tree is never modified.
287
+ */
288
+ export async function mutate(opts) {
289
+ const cwd = resolve(opts.cwd ?? process.cwd());
290
+ const target = resolve(cwd, opts.target);
291
+ if (!existsSync(target))
292
+ throw new MutationError(`Target file not found: ${opts.target}`);
293
+ const adapter = adapterFor(target);
294
+ if (!adapter?.generateMutants)
295
+ throw new MutationError(`Mutation is not supported for ${opts.target} yet.`);
296
+ const pkg = opts.root ? join(resolve(cwd, opts.root), 'package.json') : findUp(dirname(target), 'package.json');
297
+ if (!pkg || !existsSync(pkg))
298
+ throw new MutationError(`Could not find a package.json above ${opts.target}; pass --root.`);
299
+ const root = dirname(pkg);
300
+ const relTarget = relative(root, target);
301
+ if (relTarget.startsWith('..'))
302
+ throw new MutationError(`Target ${opts.target} is outside project root ${root}.`);
303
+ // Which tests exercise this file?
304
+ const relatedTests = findRelatedTests(root, target, adapter).map((f) => toPosix(relative(root, f)));
305
+ let command = opts.testCmd ?? null;
306
+ if (command) {
307
+ command = command.replace('{files}', relatedTests.map(shellQuote).join(' '));
308
+ }
309
+ else {
310
+ command = adapter.defaultTestCommand?.(root) ?? null;
311
+ if (!command)
312
+ throw new MutationError('Could not determine how to run tests (no "test" script in package.json). Pass --test-cmd "<command>".');
313
+ command = scopedTestCommand(resolveTestScript(root), relatedTests.map(shellQuote)) ?? command;
314
+ }
315
+ const source = readFileSync(target, 'utf8');
316
+ const all = adapter.generateMutants(target, source);
317
+ const mutants = all.slice(0, opts.maxMutants ?? 100);
318
+ const sourceLines = source.split(/\r?\n/);
319
+ const tmp = mkdtempSync(join(tmpdir(), 'honest-tests-'));
320
+ const cleanup = () => rmSync(tmp, { recursive: true, force: true });
321
+ const onSignal = (sig) => {
322
+ killRunningTests();
323
+ cleanup();
324
+ process.kill(process.pid, sig);
325
+ };
326
+ process.once('SIGINT', onSignal);
327
+ process.once('SIGTERM', onSignal);
328
+ try {
329
+ const work = prepareCopy(root, tmp);
330
+ const tmpTarget = join(work, relTarget);
331
+ // Real bin dirs, not the symlinked copy: pnpm's .bin shims resolve paths relative to their own
332
+ // location, and from inside the temp dir `../../node_modules/.pnpm` points nowhere.
333
+ const extraPath = binPaths(root);
334
+ const baseline = await runCommand(command, work, opts.timeoutMs ?? 5 * 60_000, extraPath);
335
+ if (baseline.code !== 0) {
336
+ throw new MutationError(baseline.timedOut
337
+ ? `Test command timed out before any mutation (${command}). If it starts a watcher or needs services, pass --test-cmd "<command that runs once>".`
338
+ : `Tests fail before any mutation (exit ${baseline.code}). Fix them first — mutation results would be meaningless.`, baseline.output);
339
+ }
340
+ const timeoutMs = opts.timeoutMs ?? Math.max(10_000, baseline.durationMs * 4 + 5_000);
341
+ const results = [];
342
+ for (const m of mutants) {
343
+ let outcome;
344
+ try {
345
+ writeFileSync(tmpTarget, source.slice(0, m.start) + m.replacement + source.slice(m.end));
346
+ outcome = await runCommand(command, work, timeoutMs, extraPath);
347
+ }
348
+ finally {
349
+ writeFileSync(tmpTarget, source);
350
+ }
351
+ const status = outcome.timedOut ? 'timeout' : outcome.code === 0 ? 'survived' : 'killed';
352
+ const r = { ...m, file: toPosix(relative(cwd, target)), status, sourceLine: (sourceLines[m.line - 1] ?? '').trim(), durationMs: outcome.durationMs };
353
+ results.push(r);
354
+ opts.onProgress?.(results.length, mutants.length, r);
355
+ }
356
+ const killed = results.filter((r) => r.status === 'killed').length;
357
+ const timedOut = results.filter((r) => r.status === 'timeout').length;
358
+ const survived = results.filter((r) => r.status === 'survived').length;
359
+ return {
360
+ target: toPosix(relative(cwd, target)),
361
+ root: toPosix(relative(cwd, root)) || '.',
362
+ command,
363
+ relatedTests,
364
+ baselineMs: baseline.durationMs,
365
+ generated: all.length,
366
+ tested: results.length,
367
+ killed,
368
+ survived,
369
+ timedOut,
370
+ score: results.length ? Math.round(((killed + timedOut) / results.length) * 1000) / 10 : 100,
371
+ mutants: results,
372
+ };
373
+ }
374
+ finally {
375
+ process.removeListener('SIGINT', onSignal);
376
+ process.removeListener('SIGTERM', onSignal);
377
+ cleanup();
378
+ }
379
+ }
@@ -0,0 +1,11 @@
1
+ export declare const c: {
2
+ bold: (s: string) => string;
3
+ dim: (s: string) => string;
4
+ red: (s: string) => string;
5
+ green: (s: string) => string;
6
+ yellow: (s: string) => string;
7
+ blue: (s: string) => string;
8
+ magenta: (s: string) => string;
9
+ cyan: (s: string) => string;
10
+ gray: (s: string) => string;
11
+ };
@@ -0,0 +1,13 @@
1
+ const enabled = !process.env.NO_COLOR && (process.env.FORCE_COLOR ? process.env.FORCE_COLOR !== '0' : !!process.stdout.isTTY);
2
+ const wrap = (open, close) => (s) => (enabled ? `\x1b[${open}m${s}\x1b[${close}m` : s);
3
+ export const c = {
4
+ bold: wrap(1, 22),
5
+ dim: wrap(2, 22),
6
+ red: wrap(31, 39),
7
+ green: wrap(32, 39),
8
+ yellow: wrap(33, 39),
9
+ blue: wrap(34, 39),
10
+ magenta: wrap(35, 39),
11
+ cyan: wrap(36, 39),
12
+ gray: wrap(90, 39),
13
+ };
@@ -0,0 +1,6 @@
1
+ import type { AuditResult } from '../core/engine.js';
2
+ import type { MutationReport } from '../mutate/runner.js';
3
+ export declare function formatAudit(r: AuditResult, opts?: {
4
+ verbose?: boolean;
5
+ }): string;
6
+ export declare function formatMutation(m: MutationReport): string;
@@ -0,0 +1,79 @@
1
+ import { c } from './colors.js';
2
+ const SEV = {
3
+ error: (s) => c.red(s),
4
+ warning: (s) => c.yellow(s),
5
+ info: (s) => c.blue(s),
6
+ };
7
+ function plural(n, word) {
8
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
9
+ }
10
+ export function formatAudit(r, opts = {}) {
11
+ const out = [];
12
+ let currentFile = '';
13
+ for (const f of r.findings) {
14
+ if (f.file !== currentFile) {
15
+ currentFile = f.file;
16
+ out.push('', c.bold(c.cyan(f.file)));
17
+ }
18
+ out.push(` ${c.gray(`${f.line}:${f.column}`.padEnd(7))} ${SEV[f.severity](f.severity.padEnd(7))} ${f.message} ${c.dim(f.ruleId)}`);
19
+ out.push(` ${c.gray('│')} ${c.dim(f.evidence)}`);
20
+ if (opts.verbose)
21
+ out.push(` ${c.gray('└')} ${c.green('fix:')} ${f.fix}`);
22
+ }
23
+ for (const u of r.unsupported) {
24
+ out.push(c.gray(` skipped ${u.file} (${u.language} support is on the roadmap)`));
25
+ }
26
+ for (const p of r.parseErrors) {
27
+ out.push(c.yellow(` could not parse ${p.file}: ${p.message}`));
28
+ }
29
+ out.push('');
30
+ const { error, warning, suspiciousTests } = r.summary;
31
+ if (r.filesScanned === 0 && r.parseErrors.length === 0) {
32
+ // "✓ 0 tests: no fake tests found" reads as a pass; it's really "nothing was checked".
33
+ out.push(c.yellow('No test files found. honest-tests looks for *.test.* / *.spec.* / test.* files and __tests__/ dirs ' +
34
+ '(vitest, jest, node:test, mocha-style it/describe). Pass test files or directories explicitly if yours are named differently.'));
35
+ }
36
+ else if (r.findings.length === 0) {
37
+ out.push(c.green(c.bold(`✓ ${plural(r.testsScanned, 'test')} in ${plural(r.filesScanned, 'file')}: no fake tests found.`)));
38
+ }
39
+ else {
40
+ if (suspiciousTests > 0) {
41
+ out.push(c.bold(`${c.red(String(suspiciousTests))} of ${r.testsScanned} tests can't fail (or don't test what they claim).`));
42
+ }
43
+ out.push(`${plural(r.filesScanned, 'file')}, ${plural(r.testsScanned, 'test')}: ${SEV.error(plural(error, 'error'))}, ${SEV.warning(plural(warning, 'warning'))}.` +
44
+ (opts.verbose ? '' : c.dim(' Run with --verbose for fixes.')));
45
+ }
46
+ return out.join('\n').replace(/^\n/, '');
47
+ }
48
+ export function formatMutation(m) {
49
+ const out = [];
50
+ out.push('', c.bold(`Mutation check: ${m.target}`));
51
+ out.push(c.dim(` command: ${m.command}`));
52
+ const shown = m.relatedTests.slice(0, 6).join(', ') + (m.relatedTests.length > 6 ? `, … ${m.relatedTests.length - 6} more` : '');
53
+ if (m.relatedTests.length)
54
+ out.push(c.dim(` tests: ${shown}`));
55
+ else
56
+ out.push(c.yellow(' no test file imports this target (directly or via other modules); ran the full test command'));
57
+ if (m.generated > m.tested)
58
+ out.push(c.yellow(` tested ${m.tested} of ${m.generated} mutants (raise --max-mutants for more)`));
59
+ const survivors = m.mutants.filter((x) => x.status === 'survived');
60
+ if (survivors.length) {
61
+ out.push('', c.bold(c.red(` ${plural(survivors.length, 'mutant')} survived — your tests don't actually check these lines:`)));
62
+ const byLine = new Map();
63
+ for (const s of survivors)
64
+ byLine.set(s.line, [...(byLine.get(s.line) ?? []), s]);
65
+ for (const [line, list] of byLine) {
66
+ out.push('', ` ${c.gray(`${m.target}:${line}`)} ${list[0].sourceLine}`);
67
+ for (const s of list)
68
+ out.push(` ${c.red('✗')} ${s.description}`);
69
+ }
70
+ }
71
+ const score = m.score;
72
+ const color = score >= 90 ? c.green : score >= 60 ? c.yellow : c.red;
73
+ out.push('');
74
+ out.push(` ${c.bold('Mutation score:')} ${color(c.bold(`${score}%`))} ` +
75
+ c.dim(`(${m.killed} killed, ${m.timedOut} timed out, ${m.survived} survived of ${m.tested}; baseline ${(m.baselineMs / 1000).toFixed(1)}s)`));
76
+ if (survivors.length === 0)
77
+ out.push(c.green(' ✓ Every mutation was caught. These tests actually test something.'));
78
+ return out.join('\n');
79
+ }
@@ -0,0 +1,4 @@
1
+ import type { AuditResult } from '../core/engine.js';
2
+ import type { MutationReport } from '../mutate/runner.js';
3
+ export declare const JSON_SCHEMA_VERSION = 1;
4
+ export declare function toJson(audit: AuditResult | null, mutation: MutationReport | null): string;
@@ -0,0 +1,8 @@
1
+ export const JSON_SCHEMA_VERSION = 1;
2
+ export function toJson(audit, mutation) {
3
+ return JSON.stringify({
4
+ schemaVersion: JSON_SCHEMA_VERSION,
5
+ ...(audit ?? {}),
6
+ ...(mutation ? { mutation } : {}),
7
+ }, null, 2);
8
+ }
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../core/types.js';
2
+ export declare const assertsOnMock: Rule;
@@ -0,0 +1,53 @@
1
+ import { atAssertion, code, eachTest } from './helpers.js';
2
+ export const assertsOnMock = {
3
+ id: 'asserts-on-mock',
4
+ severity: 'error',
5
+ description: 'Asserts on the return value of a mock the test configured itself — it tests the mock, not the code.',
6
+ fix: 'Call the real unit under test (which uses the mock as a dependency) and assert on what the unit produces. Assert on the mock only for interactions (toHaveBeenCalledWith).',
7
+ check(file) {
8
+ const out = [];
9
+ for (const t of eachTest(file)) {
10
+ const configured = new Map();
11
+ for (const m of t.mockSetups)
12
+ if (m.configuresReturn && !configured.has(m.target))
13
+ configured.set(m.target, m.line);
14
+ if (configured.size === 0)
15
+ continue;
16
+ const restores = t.mockRestores ?? [];
17
+ // Lines of setups that share a declaration (`const spy = vi.spyOn(o, 'm')` registers `spy` and `o.m`).
18
+ const setupLines = (target) => new Set(t.mockSetups.filter((m) => m.target === target).map((m) => m.line));
19
+ /** The mock still returns its configured value at `line` (not restored/reset in between). */
20
+ const activeAt = (target, line) => {
21
+ const lines = setupLines(target);
22
+ const setupLine = configured.get(target);
23
+ return !restores.some((r) => {
24
+ if (r.line <= setupLine || r.line >= line)
25
+ return false;
26
+ if (r.target === null || r.target === target)
27
+ return true;
28
+ return [...setupLines(r.target)].some((l) => lines.has(l));
29
+ });
30
+ };
31
+ for (const a of t.assertions) {
32
+ const s = a.subject;
33
+ if (!s || a.matcherKind === 'called' || a.matcherKind === 'none')
34
+ continue;
35
+ let hit;
36
+ if (s.kind === 'call' && s.calleeText && configured.has(s.calleeText))
37
+ hit = s.calleeText;
38
+ else if (s.kind === 'identifier' && s.aliasCalleeText && configured.has(s.aliasCalleeText))
39
+ hit = s.aliasCalleeText;
40
+ // Only `.mock.results` echoes configured return values; `.mock.calls`, `.lastCall`,
41
+ // `.invocationCallOrder`, `.instances` record what the code under test did with the mock.
42
+ else if (s.kind === 'member')
43
+ hit = [...configured.keys()].find((k) => s.text.startsWith(`${k}.mock.results`));
44
+ if (hit && !activeAt(hit, a.line))
45
+ hit = undefined;
46
+ if (hit) {
47
+ out.push(atAssertion(t, a, `Asserts on what mock ${code(hit)} returns (configured on line ${configured.get(hit)}) — this verifies the mock, not your code.`));
48
+ }
49
+ }
50
+ }
51
+ return out;
52
+ },
53
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../core/types.js';
2
+ export declare const assertsOnOwnLiteral: Rule;
@@ -0,0 +1,36 @@
1
+ import { atAssertion, code, eachTest } from './helpers.js';
2
+ /**
3
+ * Matchers that only look at the value they're given. `throws`/`called` fail on a plain value, snapshots
4
+ * are snapshot-only's call, and custom matchers (`expect(msg).toHaveBeenWarnedTimes(1)`) may use the
5
+ * literal as a key into state the code under test changed — so unknown matchers never count.
6
+ */
7
+ const VALUE_KINDS = new Set(['equal', 'truthy', 'falsy', 'defined', 'undefined', 'null', 'length', 'compare-gte', 'compare-other', 'type']);
8
+ const VALUE_MATCHERS = /^(toContain|toContainEqual|toMatch|toBeNaN|include|includes|contain|contains|match|matches|oneOf|toBeOneOf)$/;
9
+ /** Property probes see inherited properties too, so code that pollutes a prototype can make them fail. */
10
+ const PROPERTY_PROBES = /^(toHaveProperty|property|nestedProperty|haveOwnProperty|ownProperty|ownPropertyDescriptor|keys|any|all)$/;
11
+ export const assertsOnOwnLiteral = {
12
+ id: 'asserts-on-own-literal',
13
+ severity: 'error',
14
+ description: 'Asserts on an object/array literal the test just built — no code under test is involved, so it can never fail.',
15
+ fix: 'Pass the literal through the code under test and assert on what it returns. For type-only checks use a compile-time test (expectTypeOf, tsd, `// @ts-expect-error`) instead of a runtime assertion.',
16
+ check(file) {
17
+ const out = [];
18
+ for (const t of eachTest(file)) {
19
+ for (const a of t.assertions) {
20
+ const s = a.subject;
21
+ if (!s?.ownLiteral || PROPERTY_PROBES.test(a.matcher ?? ''))
22
+ continue;
23
+ if (!VALUE_KINDS.has(a.matcherKind) && !(a.matcherKind === 'other' && VALUE_MATCHERS.test(a.matcher ?? '')))
24
+ continue;
25
+ if (!a.args.every((x) => x.kind === 'literal' || x.pureLiteral))
26
+ continue;
27
+ // `expect({a: 1}).toEqual({a: 1})` is tautological-assertion's finding.
28
+ if (a.args[0] && a.args[0].text === s.text)
29
+ continue;
30
+ const { name, line } = s.ownLiteral;
31
+ out.push(atAssertion(t, a, `Asserts on ${code(s.text)}, which comes only from the literal ${code(name)} built on line ${line} — no code under test runs, so this can never fail.`));
32
+ }
33
+ }
34
+ return out;
35
+ },
36
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../core/types.js';
2
+ export declare const catchOnlyAssertion: Rule;
@@ -0,0 +1,22 @@
1
+ import { atAssertion, eachTest } from './helpers.js';
2
+ export const catchOnlyAssertion = {
3
+ id: 'catch-only-assertion',
4
+ severity: 'error',
5
+ description: 'Assertions live only in a catch block; if the code stops throwing, the catch never runs and the test passes.',
6
+ fix: 'Use expect(() => fn()).toThrow(...) / await expect(p).rejects.toThrow(...), or add expect.assertions(n) at the top of the test.',
7
+ check(file) {
8
+ const out = [];
9
+ for (const t of eachTest(file)) {
10
+ if (t.hasAssertionCountGuard)
11
+ continue;
12
+ const inCatch = t.assertions.filter((a) => a.context.inCatch && !a.context.catchGuarded);
13
+ if (inCatch.length === 0)
14
+ continue;
15
+ const onlyCatch = inCatch.length === t.assertions.length && t.helperAssertionCalls === 0;
16
+ out.push(atAssertion(t, inCatch[0], onlyCatch
17
+ ? 'All assertions are inside a catch block — if the code stops throwing, this test still passes.'
18
+ : 'Assertion inside a catch block is skipped whenever the code does not throw.', { severity: onlyCatch ? 'error' : 'warning' }));
19
+ }
20
+ return out;
21
+ },
22
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../core/types.js';
2
+ export declare const conditionalAssertion: Rule;
@@ -0,0 +1,30 @@
1
+ import { atAssertion, eachTest } from './helpers.js';
2
+ export const conditionalAssertion = {
3
+ id: 'conditional-assertion',
4
+ severity: 'warning',
5
+ description: 'Assertion is guarded by an if/ternary/&&, so the test passes without checking anything when the guard is false.',
6
+ fix: 'Assert the condition itself (expect(result).toBeDefined()), then assert unconditionally. Split data-dependent branches into separate tests.',
7
+ check(file) {
8
+ const out = [];
9
+ for (const t of eachTest(file)) {
10
+ // A guard that re-tests something already asserted (`expect(r.success).toBe(false); if (!r.success) ...`)
11
+ // only narrows types — the branch is guaranteed to run. The earlier assertion may itself sit behind
12
+ // such a narrowing guard (nested `if (!r.success) { expect(i.code)...; if (i.code === 'x') ... }`).
13
+ const memo = new Map();
14
+ const runsAlways = (x) => {
15
+ if (!x.context.inConditional)
16
+ return true;
17
+ let r = memo.get(x);
18
+ if (r === undefined) {
19
+ r = (x.context.guards ?? []).every((g) => t.assertions.some((p) => p.line < x.line && p.subject?.text === g && runsAlways(p)));
20
+ memo.set(x, r);
21
+ }
22
+ return r;
23
+ };
24
+ const a = t.assertions.find((x) => x.context.inConditional && !x.context.inCatch && !runsAlways(x));
25
+ if (a)
26
+ out.push(atAssertion(t, a, 'Assertion only runs when a condition holds — when it does not, the test passes silently.'));
27
+ }
28
+ return out;
29
+ },
30
+ };