@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,25 @@
1
+ import type { LanguageAdapter } from '../../core/types.js';
2
+ export declare const jsAdapter: LanguageAdapter;
3
+ export interface TestScript {
4
+ runner: 'vitest' | 'jest' | 'unknown';
5
+ /** The script that actually runs tests, after following `npm run x` / `pnpm x` delegation. */
6
+ script: string;
7
+ /** The script is one plain command (no `&&`, `;`, pipes) we can run directly with extra arguments. */
8
+ simple: boolean;
9
+ /** `test` only delegates to another script (`npm run test:unit`). */
10
+ delegated: boolean;
11
+ /** Coverage is switched on (`--coverage` in the script, or `enabled: true` in the runner config). */
12
+ coverage: boolean;
13
+ }
14
+ /** Follow `"test": "npm run test:unit"` chains to the script that runs the test runner. */
15
+ export declare function resolveTestScript(root: string): TestScript;
16
+ /** Which runner a package.json test script uses — lets us pass test files through. */
17
+ export declare function detectRunner(root: string): 'vitest' | 'jest' | 'unknown';
18
+ /**
19
+ * How to run `files` (already shell-quoted) once, never in watch mode:
20
+ * - `test` runs vitest/jest itself: `npm test --silent -- [--run] <files>` (keeps npm pre/post hooks).
21
+ * - `test` delegates (`npm run test:vitest`): run the plain leaf script directly, since npm would
22
+ * swallow `--run` as one of its own flags.
23
+ * Returns null when we can't safely add arguments (unknown runner, `a && b` chains).
24
+ */
25
+ export declare function scopedTestCommand(ts: TestScript, files: string[]): string | null;
@@ -0,0 +1,122 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { generateJsMutants } from './mutants.js';
4
+ import { parseJsTestFile } from './parse-tests.js';
5
+ const EXT = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'];
6
+ const TEST_FILE = /(\.|_|-)(test|spec)\.(m|c)?[jt]sx?$/i;
7
+ /** Co-located `foo/test.ts` / `foo/spec.js` (date-fns style). */
8
+ const BARE_TEST_FILE = /(^|\/)(test|spec)\.(m|c)?[jt]sx?$/i;
9
+ function stripExt(p) {
10
+ return p.replace(/\.(m|c)?[jt]sx?$/, '').replace(/\/index$/, '');
11
+ }
12
+ const IMPORT_RE = /(?:from\s+|import\s*\(\s*|require\s*\(\s*|mock\s*\(\s*|import\s+)['"]([^'"]+)['"]/g;
13
+ /** Relative imports of a JS/TS file, as extension-less absolute module ids. */
14
+ function localImports(path, source) {
15
+ const out = [];
16
+ for (const m of source.matchAll(IMPORT_RE)) {
17
+ const spec = m[1];
18
+ if (spec.startsWith('.'))
19
+ out.push(stripExt(resolve(dirname(path), spec)));
20
+ }
21
+ return out;
22
+ }
23
+ export const jsAdapter = {
24
+ id: 'js',
25
+ extensions: EXT,
26
+ isTestFile(path) {
27
+ const p = path.replace(/\\/g, '/');
28
+ return TEST_FILE.test(p) || BARE_TEST_FILE.test(p) || (/\/__tests__\//.test(p) && /\.(m|c)?[jt]sx?$/.test(p));
29
+ },
30
+ parseTestFile: parseJsTestFile,
31
+ generateMutants: generateJsMutants,
32
+ testImportsTarget(testPath, testSource, targetPath) {
33
+ return localImports(testPath, testSource).includes(stripExt(resolve(targetPath)));
34
+ },
35
+ localImports,
36
+ moduleId: (path) => stripExt(resolve(path)),
37
+ defaultTestCommand(root) {
38
+ const pkgPath = join(root, 'package.json');
39
+ if (!existsSync(pkgPath))
40
+ return null;
41
+ try {
42
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
43
+ const script = pkg.scripts?.test;
44
+ if (!script || /no test specified/.test(script))
45
+ return null;
46
+ return 'npm test --silent';
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ },
52
+ };
53
+ const RUNNER_CONFIGS = [
54
+ 'vitest.config.ts', 'vitest.config.mts', 'vitest.config.js', 'vitest.config.mjs',
55
+ 'vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs',
56
+ 'jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs',
57
+ ];
58
+ function coverageEnabledInConfig(root) {
59
+ for (const name of RUNNER_CONFIGS) {
60
+ try {
61
+ const text = readFileSync(join(root, name), 'utf8');
62
+ if (/coverage\s*:\s*\{[^}]*enabled\s*:\s*true|collectCoverage\s*:\s*true/.test(text))
63
+ return true;
64
+ }
65
+ catch {
66
+ /* not there */
67
+ }
68
+ }
69
+ return false;
70
+ }
71
+ /** Follow `"test": "npm run test:unit"` chains to the script that runs the test runner. */
72
+ export function resolveTestScript(root) {
73
+ let scripts = {};
74
+ try {
75
+ scripts = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts ?? {};
76
+ }
77
+ catch {
78
+ /* ignore */
79
+ }
80
+ let script = scripts.test ?? '';
81
+ let delegated = false;
82
+ for (let hops = 0; hops < 5; hops++) {
83
+ const m = /^\s*(?:npm run|npm run-script|pnpm(?: run)?|yarn(?: run)?|bun run)\s+([\w:.-]+)\s*$/.exec(script);
84
+ if (!m || scripts[m[1]] === undefined)
85
+ break;
86
+ script = scripts[m[1]];
87
+ delegated = true;
88
+ }
89
+ const runner = /\bvitest\b/.test(script) ? 'vitest' : /\bjest\b/.test(script) ? 'jest' : 'unknown';
90
+ return {
91
+ runner,
92
+ script: script.trim(),
93
+ simple: !/&&|\|\||;|\||`|\$\(/.test(script),
94
+ delegated,
95
+ coverage: /--coverage\b(?!=false|\.enabled=false)/.test(script) || coverageEnabledInConfig(root),
96
+ };
97
+ }
98
+ /** Which runner a package.json test script uses — lets us pass test files through. */
99
+ export function detectRunner(root) {
100
+ return resolveTestScript(root).runner;
101
+ }
102
+ /**
103
+ * How to run `files` (already shell-quoted) once, never in watch mode:
104
+ * - `test` runs vitest/jest itself: `npm test --silent -- [--run] <files>` (keeps npm pre/post hooks).
105
+ * - `test` delegates (`npm run test:vitest`): run the plain leaf script directly, since npm would
106
+ * swallow `--run` as one of its own flags.
107
+ * Returns null when we can't safely add arguments (unknown runner, `a && b` chains).
108
+ */
109
+ export function scopedTestCommand(ts, files) {
110
+ if (ts.runner === 'unknown')
111
+ return null;
112
+ // A bare `vitest` script starts a watcher; mutation runs must exit.
113
+ const needsRun = ts.runner === 'vitest' && !/\bvitest\s+(run|bench)\b|--run\b|--watch=false|--no-watch\b/.test(ts.script);
114
+ // Coverage only slows mutant runs down, and global thresholds fail when we run a subset of tests.
115
+ const noCoverage = ts.coverage ? [ts.runner === 'vitest' ? '--coverage.enabled=false' : '--coverage=false'] : [];
116
+ const extra = [...(needsRun ? ['--run'] : []), ...noCoverage, ...files];
117
+ if (!ts.delegated)
118
+ return extra.length ? `npm test --silent -- ${extra.join(' ')}` : 'npm test --silent';
119
+ if (!ts.simple)
120
+ return null;
121
+ return [ts.script, ...extra].join(' ');
122
+ }
@@ -0,0 +1,8 @@
1
+ import type { Mutant } from '../../core/types.js';
2
+ /**
3
+ * Generate simple, high-signal mutants for a JS/TS source file:
4
+ * operator flips, boolean flips, negation removal, numeric tweaks, and
5
+ * return-value replacement. Type-only syntax is never touched.
6
+ */
7
+ export declare function generateJsMutants(path: string, source: string): Mutant[];
8
+ export declare function applyMutant(source: string, m: Mutant): string;
@@ -0,0 +1,190 @@
1
+ import { isCall, isMember, parseSource, textOf, walk } from './ast.js';
2
+ const BINARY_SWAPS = {
3
+ '===': '!==',
4
+ '!==': '===',
5
+ '==': '!=',
6
+ '!=': '==',
7
+ '<': '<=',
8
+ '<=': '<',
9
+ '>': '>=',
10
+ '>=': '>',
11
+ '+': '-',
12
+ '-': '+',
13
+ '*': '/',
14
+ '/': '*',
15
+ '%': '*',
16
+ };
17
+ const LOGICAL_SWAPS = {
18
+ '&&': '||',
19
+ '||': '&&',
20
+ };
21
+ const ASSIGN_SWAPS = {
22
+ '+=': '-=',
23
+ '-=': '+=',
24
+ '*=': '/=',
25
+ '/=': '*=',
26
+ };
27
+ /** Calls whose arguments we never mutate (noise, not behavior). */
28
+ const SKIP_CALLEE = /^(console|logger|log|debug|assert|invariant)$/;
29
+ function isStringish(node) {
30
+ return node.type === 'StringLiteral' || node.type === 'TemplateLiteral';
31
+ }
32
+ /**
33
+ * Generate simple, high-signal mutants for a JS/TS source file:
34
+ * operator flips, boolean flips, negation removal, numeric tweaks, and
35
+ * return-value replacement. Type-only syntax is never touched.
36
+ */
37
+ export function generateJsMutants(path, source) {
38
+ const ast = parseSource(path, source);
39
+ const mutants = [];
40
+ const seen = new Set();
41
+ const lineStarts = [0];
42
+ for (let i = 0; i < source.length; i++)
43
+ if (source[i] === '\n')
44
+ lineStarts.push(i + 1);
45
+ const position = (offset) => {
46
+ let lo = 0;
47
+ let hi = lineStarts.length - 1;
48
+ while (lo < hi) {
49
+ const mid = (lo + hi + 1) >> 1;
50
+ if (lineStarts[mid] <= offset)
51
+ lo = mid;
52
+ else
53
+ hi = mid - 1;
54
+ }
55
+ return { line: lo + 1, column: offset - lineStarts[lo] + 1 };
56
+ };
57
+ const short = (s) => {
58
+ const one = s.replace(/\s+/g, ' ');
59
+ return one.length > 40 ? one.slice(0, 39) + '…' : one;
60
+ };
61
+ const add = (_node, start, end, replacement, operator, description) => {
62
+ const key = `${start}:${end}:${replacement}`;
63
+ if (seen.has(key))
64
+ return;
65
+ seen.add(key);
66
+ const original = source.slice(start, end);
67
+ if (original === replacement)
68
+ return;
69
+ mutants.push({
70
+ id: 0,
71
+ file: path,
72
+ ...position(start),
73
+ start,
74
+ end,
75
+ original,
76
+ replacement,
77
+ operator,
78
+ description,
79
+ });
80
+ };
81
+ walk(ast, (node, ancestors) => {
82
+ // Never mutate types, imports, or logging.
83
+ if (node.type.startsWith('TS') && !['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression'].includes(node.type)) {
84
+ return false;
85
+ }
86
+ if (node.type === 'ImportDeclaration' || node.type === 'ExportAllDeclaration' || node.type === 'Directive')
87
+ return false;
88
+ if (isCall(node)) {
89
+ let callee = node.callee;
90
+ while (isMember(callee))
91
+ callee = callee.object;
92
+ if (callee.type === 'Identifier' && SKIP_CALLEE.test(callee.name))
93
+ return false;
94
+ }
95
+ const parent = ancestors[ancestors.length - 1];
96
+ switch (node.type) {
97
+ case 'BinaryExpression': {
98
+ const swap = BINARY_SWAPS[node.operator];
99
+ if (!swap)
100
+ break;
101
+ if ((node.operator === '+' || node.operator === '-') && (isStringish(node.left) || isStringish(node.right)))
102
+ break;
103
+ const left = node.left;
104
+ const opStart = source.indexOf(node.operator, left.end);
105
+ if (opStart < 0 || opStart >= node.right.start)
106
+ break;
107
+ add(node, opStart, opStart + node.operator.length, swap, 'binary', `\`${node.operator}\` → \`${swap}\``);
108
+ break;
109
+ }
110
+ case 'LogicalExpression': {
111
+ const swap = LOGICAL_SWAPS[node.operator];
112
+ if (!swap)
113
+ break;
114
+ const opStart = source.indexOf(node.operator, node.left.end);
115
+ if (opStart < 0 || opStart >= node.right.start)
116
+ break;
117
+ add(node, opStart, opStart + node.operator.length, swap, 'logical', `\`${node.operator}\` → \`${swap}\``);
118
+ break;
119
+ }
120
+ case 'AssignmentExpression': {
121
+ const swap = ASSIGN_SWAPS[node.operator];
122
+ if (!swap)
123
+ break;
124
+ const opStart = source.indexOf(node.operator, node.left.end);
125
+ if (opStart < 0 || opStart >= node.right.start)
126
+ break;
127
+ add(node, opStart, opStart + node.operator.length, swap, 'assignment', `\`${node.operator}\` → \`${swap}\``);
128
+ break;
129
+ }
130
+ case 'BooleanLiteral': {
131
+ add(node, node.start, node.end, String(!node.value), 'boolean', `\`${node.value}\` → \`${!node.value}\``);
132
+ break;
133
+ }
134
+ case 'UnaryExpression': {
135
+ if (node.operator === '!') {
136
+ const arg = textOf(source, node.argument);
137
+ add(node, node.start, node.end, `(${arg})`, 'negation', `removed \`!\` from \`!${short(arg)}\``);
138
+ }
139
+ break;
140
+ }
141
+ case 'NumericLiteral': {
142
+ // Skip array indices and object keys; they are rarely behavior and mostly noise.
143
+ if (parent && isMember(parent) && parent.property === node)
144
+ break;
145
+ if (parent && parent.type === 'ObjectProperty' && parent.key === node)
146
+ break;
147
+ const raw = textOf(source, node);
148
+ if (!/^\d+(\.\d+)?$/.test(raw))
149
+ break;
150
+ const next = node.value === 0 ? '1' : node.value === 1 ? '0' : String(node.value + 1);
151
+ add(node, node.start, node.end, next, 'number', `\`${raw}\` → \`${next}\``);
152
+ break;
153
+ }
154
+ case 'ReturnStatement': {
155
+ const arg = node.argument;
156
+ if (!arg)
157
+ break;
158
+ if (arg.type === 'BooleanLiteral' || (arg.type === 'Identifier' && arg.name === 'undefined'))
159
+ break;
160
+ const fn = [...ancestors].reverse().find((a) => a.type.includes('Function'));
161
+ if (fn && fn.generator)
162
+ break;
163
+ add(node, arg.start, arg.end, 'undefined', 'return', `return value replaced with \`undefined\``);
164
+ break;
165
+ }
166
+ case 'ArrowFunctionExpression': {
167
+ if (node.body.type !== 'BlockStatement' && node.body.type !== 'BooleanLiteral') {
168
+ const body = node.body;
169
+ add(body, body.start, body.end, 'undefined', 'return', `arrow return value replaced with \`undefined\``);
170
+ }
171
+ break;
172
+ }
173
+ case 'IfStatement': {
174
+ const test = node.test;
175
+ add(test, test.start, test.end, 'true', 'condition', `condition \`${short(textOf(source, test))}\` → \`true\``);
176
+ add(test, test.start, test.end, 'false', 'condition', `condition \`${short(textOf(source, test))}\` → \`false\``);
177
+ break;
178
+ }
179
+ default:
180
+ break;
181
+ }
182
+ return undefined;
183
+ });
184
+ mutants.sort((a, b) => a.start - b.start || a.end - b.end);
185
+ mutants.forEach((m, i) => (m.id = i + 1));
186
+ return mutants;
187
+ }
188
+ export function applyMutant(source, m) {
189
+ return source.slice(0, m.start) + m.replacement + source.slice(m.end);
190
+ }
@@ -0,0 +1,5 @@
1
+ import type { MatcherKind, TestFileModel } from '../../core/types.js';
2
+ export declare function matcherKindOf(name: string | null): MatcherKind;
3
+ /** Expected unit-under-test name for a test file: `pricing.test.ts` -> `pricing`. */
4
+ export declare function unitNameForTestFile(path: string): string;
5
+ export declare function parseJsTestFile(path: string, source: string): TestFileModel;