@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,17 @@
1
+ import type { LanguageAdapter } from '../../core/types.js';
2
+ /**
3
+ * pytest adapter — PLANNED (see ROADMAP in README).
4
+ *
5
+ * The rule engine is language-neutral: an adapter only has to turn a test file
6
+ * into a `TestFileModel`. For pytest that means mapping:
7
+ * - `def test_*` / `class Test*` methods -> TestCase
8
+ * - `assert a == b`, `assert x`, `pytest.raises` -> Assertion (matcherKind 'equal' | 'truthy' | 'throws')
9
+ * - `@pytest.mark.skip` / `xfail` -> modifier 'skip'
10
+ * - `mocker.patch('pkg.unit.fn')` / `@patch` -> ModuleMock / MockSetup
11
+ * - `mock.return_value = x` -> MockSetup { configuresReturn: true }
12
+ * Mutation: operator flips via Python's own `ast` module in a subprocess.
13
+ *
14
+ * Until then, Python test files are discovered and reported as "not yet supported"
15
+ * instead of being silently ignored.
16
+ */
17
+ export declare const pythonAdapter: LanguageAdapter;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * pytest adapter — PLANNED (see ROADMAP in README).
3
+ *
4
+ * The rule engine is language-neutral: an adapter only has to turn a test file
5
+ * into a `TestFileModel`. For pytest that means mapping:
6
+ * - `def test_*` / `class Test*` methods -> TestCase
7
+ * - `assert a == b`, `assert x`, `pytest.raises` -> Assertion (matcherKind 'equal' | 'truthy' | 'throws')
8
+ * - `@pytest.mark.skip` / `xfail` -> modifier 'skip'
9
+ * - `mocker.patch('pkg.unit.fn')` / `@patch` -> ModuleMock / MockSetup
10
+ * - `mock.return_value = x` -> MockSetup { configuresReturn: true }
11
+ * Mutation: operator flips via Python's own `ast` module in a subprocess.
12
+ *
13
+ * Until then, Python test files are discovered and reported as "not yet supported"
14
+ * instead of being silently ignored.
15
+ */
16
+ export const pythonAdapter = {
17
+ id: 'python',
18
+ extensions: ['.py'],
19
+ planned: true,
20
+ isTestFile(path) {
21
+ return /(^|\/)(test_[^/]*|[^/]*_test)\.py$/.test(path.replace(/\\/g, '/'));
22
+ },
23
+ parseTestFile(path, source) {
24
+ return { file: path, language: 'python', tests: [], moduleMocks: [], unitSpies: [], lines: source.split(/\r?\n/) };
25
+ },
26
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './run.js';
3
+ // `honest-tests audit | head` closes the pipe early; that's not an error worth a stack trace.
4
+ for (const stream of [process.stdout, process.stderr]) {
5
+ stream.on('error', (err) => {
6
+ if (err.code === 'EPIPE')
7
+ process.exit(process.exitCode ?? 0);
8
+ throw err;
9
+ });
10
+ }
11
+ run(process.argv.slice(2), {
12
+ stdout: (s) => process.stdout.write(s),
13
+ stderr: (s) => process.stderr.write(s),
14
+ cwd: process.cwd(),
15
+ progress: process.stderr.isTTY,
16
+ }).then((code) => {
17
+ process.exitCode = code;
18
+ }, (err) => {
19
+ process.stderr.write(`honest-tests: ${err?.stack ?? String(err)}\n`);
20
+ process.exitCode = 2;
21
+ });
@@ -0,0 +1,44 @@
1
+ import type { Finding, Rule, TestFileModel } from './types.js';
2
+ export declare const DEFAULT_IGNORED_DIRS: Set<string>;
3
+ export interface AuditOptions {
4
+ paths: string[];
5
+ cwd?: string;
6
+ /** Glob-ish patterns relative to cwd (`fixtures/**`, `**\/legacy/*`, or a bare dir name). */
7
+ ignore?: string[];
8
+ /** Restrict to these rule ids. */
9
+ only?: string[];
10
+ /** Exclude these rule ids. */
11
+ disable?: string[];
12
+ }
13
+ export interface AuditResult {
14
+ tool: 'honest-tests';
15
+ version: string;
16
+ cwd: string;
17
+ filesScanned: number;
18
+ testsScanned: number;
19
+ findings: Finding[];
20
+ /** Test files found for languages whose adapter is still planned (e.g. pytest). */
21
+ unsupported: Array<{
22
+ file: string;
23
+ language: string;
24
+ }>;
25
+ parseErrors: Array<{
26
+ file: string;
27
+ message: string;
28
+ }>;
29
+ summary: {
30
+ error: number;
31
+ warning: number;
32
+ info: number;
33
+ /** Distinct tests with at least one error-level finding. */
34
+ suspiciousTests: number;
35
+ };
36
+ }
37
+ export declare function globToRegExp(pattern: string): RegExp;
38
+ /** Path relative to cwd, or absolute when the file is outside it (`../../../tmp/x` helps nobody). */
39
+ export declare function displayPath(cwd: string, abs: string): string;
40
+ export declare function discoverTestFiles(paths: string[], cwd: string, ignore?: string[]): string[];
41
+ export declare function runRules(model: TestFileModel, rules: Rule[], displayPath: string): Finding[];
42
+ export declare function selectRules(opts: Pick<AuditOptions, 'only' | 'disable'>): Rule[];
43
+ export declare function auditSource(path: string, source: string, opts?: Pick<AuditOptions, 'only' | 'disable'>): Finding[];
44
+ export declare function audit(opts: AuditOptions, version?: string): AuditResult;
@@ -0,0 +1,205 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative, resolve, sep } from 'node:path';
3
+ import { adapterFor } from '../adapters/index.js';
4
+ import { rules as allRules } from '../rules/index.js';
5
+ export const DEFAULT_IGNORED_DIRS = new Set([
6
+ 'node_modules',
7
+ '.git',
8
+ 'dist',
9
+ 'build',
10
+ 'coverage',
11
+ '.next',
12
+ '.nuxt',
13
+ 'out',
14
+ '.turbo',
15
+ '.cache',
16
+ 'vendor',
17
+ '.venv',
18
+ 'venv',
19
+ '__pycache__',
20
+ ]);
21
+ export function globToRegExp(pattern) {
22
+ let p = pattern.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
23
+ if (!/[*?]/.test(p)) {
24
+ // bare path/dir name: match it as a path segment sequence anywhere
25
+ const esc = p.replace(/[.+^${}()|[\]\\]/g, '\\$&');
26
+ return new RegExp(`(^|/)${esc}(/|$)`);
27
+ }
28
+ let re = '';
29
+ for (let i = 0; i < p.length; i++) {
30
+ const c = p[i];
31
+ if (c === '*') {
32
+ if (p[i + 1] === '*') {
33
+ re += '.*';
34
+ i++;
35
+ if (p[i + 1] === '/')
36
+ i++;
37
+ }
38
+ else
39
+ re += '[^/]*';
40
+ }
41
+ else if (c === '?')
42
+ re += '[^/]';
43
+ else
44
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
45
+ }
46
+ return new RegExp(`^${re}(/|$)`);
47
+ }
48
+ function toPosix(p) {
49
+ return p.split(sep).join('/');
50
+ }
51
+ /** Path relative to cwd, or absolute when the file is outside it (`../../../tmp/x` helps nobody). */
52
+ export function displayPath(cwd, abs) {
53
+ const rel = toPosix(relative(cwd, abs));
54
+ return !rel || rel.startsWith('../') || rel === '..' ? toPosix(abs) : rel;
55
+ }
56
+ export function discoverTestFiles(paths, cwd, ignore = []) {
57
+ const ignoreRes = ignore.map(globToRegExp);
58
+ const isIgnored = (abs) => {
59
+ const rel = toPosix(relative(cwd, abs));
60
+ return ignoreRes.some((re) => re.test(rel));
61
+ };
62
+ const out = new Set();
63
+ const visit = (abs, explicit) => {
64
+ let st;
65
+ try {
66
+ st = statSync(abs);
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ if (st.isDirectory()) {
72
+ if (!explicit && (DEFAULT_IGNORED_DIRS.has(abs.split(sep).pop()) || isIgnored(abs)))
73
+ return;
74
+ for (const entry of readdirSync(abs).sort())
75
+ visit(join(abs, entry), false);
76
+ return;
77
+ }
78
+ if (!st.isFile())
79
+ return;
80
+ if (!explicit && isIgnored(abs))
81
+ return;
82
+ const adapter = adapterFor(abs);
83
+ if (adapter && (explicit || adapter.isTestFile(toPosix(abs))))
84
+ out.add(abs);
85
+ };
86
+ for (const p of paths)
87
+ visit(resolve(cwd, p), true);
88
+ return [...out];
89
+ }
90
+ /** Parse `honest-tests-disable*` comments. Works for `//`, `/* *\/` and `#` comments. */
91
+ function suppressions(lines) {
92
+ const fileLevel = new Set();
93
+ let fileAll = false;
94
+ const perLine = new Map();
95
+ const parseIds = (rest) => rest
96
+ .replace(/\*\/.*$/, '')
97
+ .split(/[\s,]+/)
98
+ .map((s) => s.trim())
99
+ .filter((s) => /^[a-z][a-z0-9-]*$/.test(s));
100
+ lines.forEach((line, i) => {
101
+ const m = /honest-tests-(disable-next-line|disable-line|disable)\b(.*)$/.exec(line);
102
+ if (!m)
103
+ return;
104
+ const ids = parseIds(m[2] ?? '');
105
+ if (m[1] === 'disable') {
106
+ if (ids.length === 0)
107
+ fileAll = true;
108
+ ids.forEach((id) => fileLevel.add(id));
109
+ return;
110
+ }
111
+ const target = m[1] === 'disable-next-line' ? i + 2 : i + 1;
112
+ perLine.set(target, ids.length === 0 ? 'all' : new Set(ids));
113
+ });
114
+ return (f) => {
115
+ if (fileAll || fileLevel.has(f.ruleId))
116
+ return true;
117
+ const s = perLine.get(f.line);
118
+ return s === 'all' || (s instanceof Set && s.has(f.ruleId));
119
+ };
120
+ }
121
+ export function runRules(model, rules, displayPath) {
122
+ const suppressed = suppressions(model.lines);
123
+ const findings = [];
124
+ for (const rule of rules) {
125
+ for (const raw of rule.check(model)) {
126
+ const f = {
127
+ ruleId: rule.id,
128
+ severity: raw.severity ?? rule.severity,
129
+ file: displayPath,
130
+ line: raw.line,
131
+ column: raw.column,
132
+ message: raw.message,
133
+ evidence: raw.evidence,
134
+ ...(raw.test !== undefined ? { test: raw.test } : {}),
135
+ fix: raw.fix ?? rule.fix,
136
+ };
137
+ if (!suppressed(f))
138
+ findings.push(f);
139
+ }
140
+ }
141
+ return findings.sort((a, b) => a.line - b.line || a.column - b.column || a.ruleId.localeCompare(b.ruleId));
142
+ }
143
+ export function selectRules(opts) {
144
+ let selected = allRules;
145
+ if (opts.only?.length)
146
+ selected = selected.filter((r) => opts.only.includes(r.id));
147
+ if (opts.disable?.length)
148
+ selected = selected.filter((r) => !opts.disable.includes(r.id));
149
+ return selected;
150
+ }
151
+ export function auditSource(path, source, opts = {}) {
152
+ const adapter = adapterFor(path);
153
+ if (!adapter)
154
+ throw new Error(`No adapter for ${path}`);
155
+ return runRules(adapter.parseTestFile(path, source), selectRules(opts), path);
156
+ }
157
+ const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
158
+ export function audit(opts, version = '0.0.0') {
159
+ const cwd = resolve(opts.cwd ?? process.cwd());
160
+ const files = discoverTestFiles(opts.paths.length ? opts.paths : ['.'], cwd, opts.ignore);
161
+ const rules = selectRules(opts);
162
+ const findings = [];
163
+ const unsupported = [];
164
+ const parseErrors = [];
165
+ let testsScanned = 0;
166
+ let filesScanned = 0;
167
+ for (const abs of files) {
168
+ const adapter = adapterFor(abs);
169
+ const display = displayPath(cwd, abs);
170
+ if (adapter.planned) {
171
+ unsupported.push({ file: display, language: adapter.id });
172
+ continue;
173
+ }
174
+ let model;
175
+ try {
176
+ model = adapter.parseTestFile(abs, readFileSync(abs, 'utf8'));
177
+ }
178
+ catch (e) {
179
+ parseErrors.push({ file: display, message: e.message });
180
+ continue;
181
+ }
182
+ filesScanned++;
183
+ testsScanned += model.tests.filter((t) => t.kind === 'test').length;
184
+ findings.push(...runRules(model, rules, display));
185
+ }
186
+ findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.ruleId.localeCompare(b.ruleId));
187
+ // "Can't fail" = an error-level finding about the test's verification (focus markers don't count).
188
+ const suspicious = new Set(findings.filter((f) => f.severity === 'error' && f.test && f.ruleId !== 'focused-test').map((f) => `${f.file}::${f.test}`));
189
+ return {
190
+ tool: 'honest-tests',
191
+ version,
192
+ cwd,
193
+ filesScanned,
194
+ testsScanned,
195
+ findings,
196
+ unsupported,
197
+ parseErrors,
198
+ summary: {
199
+ error: findings.filter((f) => f.severity === 'error').length,
200
+ warning: findings.filter((f) => f.severity === 'warning').length,
201
+ info: findings.filter((f) => f.severity === 'info').length,
202
+ suspiciousTests: suspicious.size,
203
+ },
204
+ };
205
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Language-neutral model of a test file.
3
+ *
4
+ * Language adapters (JS/TS today, pytest next) parse source into this model.
5
+ * Rules only ever see the model, so a new adapter gets every rule for free.
6
+ */
7
+ export type Severity = 'error' | 'warning' | 'info';
8
+ export type SubjectKind = 'literal' | 'identifier' | 'member' | 'call' | 'object' | 'array' | 'function' | 'new' | 'typeof' | 'other';
9
+ export interface ExprInfo {
10
+ /** Normalized source text (whitespace collapsed). */
11
+ text: string;
12
+ kind: SubjectKind;
13
+ /** For calls: normalized callee text, e.g. `mockFetch` or `api.get`. */
14
+ calleeText?: string;
15
+ /**
16
+ * If the subject is an identifier bound in the test to the result of a call
17
+ * (`const r = fn()`), the callee text of that call.
18
+ */
19
+ aliasCalleeText?: string;
20
+ /** For literals: the literal's runtime truthiness / undefined-ness. */
21
+ literalValue?: string | number | boolean | null | undefined;
22
+ isUndefinedLiteral?: boolean;
23
+ /**
24
+ * Set when the value is built purely from literals constructed in this test
25
+ * (`const p = { service: 's3' }; expect(p.service)`): no call, import or outer value is involved,
26
+ * so the value is fixed before any code under test runs. `name`/`line` locate the literal binding.
27
+ */
28
+ ownLiteral?: {
29
+ name: string;
30
+ line: number;
31
+ };
32
+ /** Built only from literals and test-local literal bindings (`{ a: 1 }`, `[x, 'y']`); implied by `ownLiteral`. */
33
+ pureLiteral?: boolean;
34
+ }
35
+ /** Normalized intent of an assertion matcher, independent of framework spelling. */
36
+ export type MatcherKind = 'equal' | 'truthy' | 'falsy' | 'defined' | 'undefined' | 'null' | 'length' | 'compare-gte' | 'compare-other' | 'snapshot' | 'throws' | 'called' | 'type' | 'other'
37
+ /** `expect(x)` with no matcher, or matcher referenced but never called. */
38
+ | 'none';
39
+ export interface AssertionContext {
40
+ /** Inside a `try` block whose `catch` does not rethrow. */
41
+ inSwallowingTry: boolean;
42
+ /** Inside a `catch` clause. */
43
+ inCatch: boolean;
44
+ /** Inside a `catch` whose `try` block calls fail()/throws after the risky call, so skipping the catch fails the test. */
45
+ catchGuarded: boolean;
46
+ /** Inside an `if`/ternary/`&&` guard (branches where every path asserts don't count). */
47
+ inConditional: boolean;
48
+ /**
49
+ * Normalized text of the value each enclosing guard tests (`!r.success` -> `r.success`).
50
+ * Lets rules excuse type-narrowing guards whose condition was already asserted.
51
+ */
52
+ guards?: string[];
53
+ }
54
+ export interface Assertion {
55
+ line: number;
56
+ column: number;
57
+ /** Source line(s) of the assertion, trimmed — used as evidence. */
58
+ text: string;
59
+ style: 'expect' | 'assert';
60
+ subject: ExprInfo | null;
61
+ matcher: string | null;
62
+ matcherKind: MatcherKind;
63
+ negated: boolean;
64
+ args: ExprInfo[];
65
+ /** `.resolves` / `.rejects` used. */
66
+ async: boolean;
67
+ /** Awaited or returned (only meaningful when `async`). */
68
+ awaitedOrReturned: boolean;
69
+ context: AssertionContext;
70
+ }
71
+ export interface MockSetup {
72
+ /** Normalized text of the mocked callable, e.g. `getUser` or `api.get`. */
73
+ target: string;
74
+ line: number;
75
+ /** Whether a return value / implementation was configured. */
76
+ configuresReturn: boolean;
77
+ }
78
+ /** `spy.mockRestore()` / `fn.mockReset()` (target set) or `vi.restoreAllMocks()` (target null). */
79
+ export interface MockRestore {
80
+ target: string | null;
81
+ line: number;
82
+ }
83
+ export type TestModifier = 'only' | 'skip' | 'todo' | null;
84
+ export interface TestCase {
85
+ name: string;
86
+ kind: 'test' | 'suite';
87
+ line: number;
88
+ endLine: number;
89
+ modifier: TestModifier;
90
+ /** Source text of the call head, e.g. `it.only('adds', ...` — evidence. */
91
+ headText: string;
92
+ /** False when the test body is not an inline function we could analyze. */
93
+ analyzable: boolean;
94
+ assertions: Assertion[];
95
+ /** Calls to helpers that look like assertions (`expectValid(x)`, `assertOk()`, `request.expect(200)`). */
96
+ helperAssertionCalls: number;
97
+ /** `expect.assertions(n)` / `expect.hasAssertions()` present. */
98
+ hasAssertionCountGuard: boolean;
99
+ /** Mocks configured inside this test (or in file-level setup visible to it). */
100
+ mockSetups: MockSetup[];
101
+ /** Mock restores/resets inside this test body; a configured mock is inactive after one. */
102
+ mockRestores?: MockRestore[];
103
+ }
104
+ export interface ModuleMock {
105
+ specifier: string;
106
+ line: number;
107
+ text: string;
108
+ /** The adapter's judgement that this specifier is the module under test. */
109
+ isUnitUnderTest: boolean;
110
+ /** The mock keeps the real module and overrides only some exports. */
111
+ partial: boolean;
112
+ }
113
+ export interface SpyOnUnit {
114
+ /** e.g. `pricing.calculateTotal` */
115
+ target: string;
116
+ line: number;
117
+ text: string;
118
+ }
119
+ export interface TestFileModel {
120
+ file: string;
121
+ language: string;
122
+ tests: TestCase[];
123
+ moduleMocks: ModuleMock[];
124
+ /** spies on exports of the module under test that replace its behavior. */
125
+ unitSpies: SpyOnUnit[];
126
+ /** Raw source lines (for evidence + disable comments). */
127
+ lines: string[];
128
+ }
129
+ export interface Finding {
130
+ ruleId: string;
131
+ severity: Severity;
132
+ file: string;
133
+ line: number;
134
+ column: number;
135
+ message: string;
136
+ /** The offending source line(s). */
137
+ evidence: string;
138
+ /** Name of the enclosing test, if any. */
139
+ test?: string;
140
+ /** Short actionable advice. */
141
+ fix: string;
142
+ }
143
+ /** What a rule returns; the engine fills in ruleId/file/defaults. */
144
+ export interface RawFinding {
145
+ line: number;
146
+ column: number;
147
+ message: string;
148
+ evidence: string;
149
+ test?: string;
150
+ severity?: Severity;
151
+ fix?: string;
152
+ }
153
+ export interface Rule {
154
+ id: string;
155
+ severity: Severity;
156
+ /** One-line description for `honest-tests rules`. */
157
+ description: string;
158
+ /** Default fix advice. */
159
+ fix: string;
160
+ check(file: TestFileModel): RawFinding[];
161
+ }
162
+ /** A single source mutation. Offsets index into the original source string. */
163
+ export interface Mutant {
164
+ id: number;
165
+ file: string;
166
+ line: number;
167
+ column: number;
168
+ start: number;
169
+ end: number;
170
+ original: string;
171
+ replacement: string;
172
+ operator: string;
173
+ description: string;
174
+ }
175
+ export interface LanguageAdapter {
176
+ id: string;
177
+ /** File extensions this adapter understands. */
178
+ extensions: string[];
179
+ /** True if `path` looks like a test file for this language. */
180
+ isTestFile(path: string): boolean;
181
+ /** Parse a test file into the language-neutral model. */
182
+ parseTestFile(path: string, source: string): TestFileModel;
183
+ /** Generate mutants for a source (non-test) file. Optional. */
184
+ generateMutants?(path: string, source: string): Mutant[];
185
+ /** Return true if `testSource` (a test file) appears to import `targetPath`. */
186
+ testImportsTarget?(testPath: string, testSource: string, targetPath: string): boolean;
187
+ /** Normalized ids of local modules `source` imports, comparable with `moduleId`. */
188
+ localImports?(path: string, source: string): string[];
189
+ /** Normalized id of the module at `path` (e.g. extension and `/index` stripped). */
190
+ moduleId?(path: string): string;
191
+ /** Default command to run tests in `root`, or null if unknown. */
192
+ defaultTestCommand?(root: string): string | null;
193
+ /** Set when the adapter is a placeholder (parses nothing yet). */
194
+ planned?: boolean;
195
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Language-neutral model of a test file.
3
+ *
4
+ * Language adapters (JS/TS today, pytest next) parse source into this model.
5
+ * Rules only ever see the model, so a new adapter gets every rule for free.
6
+ */
7
+ export {};
@@ -0,0 +1,6 @@
1
+ export { audit, auditSource, discoverTestFiles, type AuditOptions, type AuditResult } from './core/engine.js';
2
+ export { mutate, MutationError, type MutateOptions, type MutationReport, type MutantResult } from './mutate/runner.js';
3
+ export { rules } from './rules/index.js';
4
+ export { adapters, adapterFor } from './adapters/index.js';
5
+ export { generateJsMutants, applyMutant } from './adapters/js/mutants.js';
6
+ export type * from './core/types.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { audit, auditSource, discoverTestFiles } from './core/engine.js';
2
+ export { mutate, MutationError } from './mutate/runner.js';
3
+ export { rules } from './rules/index.js';
4
+ export { adapters, adapterFor } from './adapters/index.js';
5
+ export { generateJsMutants, applyMutant } from './adapters/js/mutants.js';
@@ -0,0 +1,59 @@
1
+ import type { LanguageAdapter, Mutant } from '../core/types.js';
2
+ export type MutantStatus = 'killed' | 'survived' | 'timeout';
3
+ export interface MutantResult extends Mutant {
4
+ status: MutantStatus;
5
+ /** Original source line, trimmed. */
6
+ sourceLine: string;
7
+ durationMs: number;
8
+ }
9
+ export interface MutateOptions {
10
+ /** Source file to mutate. */
11
+ target: string;
12
+ cwd?: string;
13
+ /** Project root (dir with package.json). Defaults to nearest ancestor of target. */
14
+ root?: string;
15
+ /** Test command. `{files}` is replaced with the related test files. Defaults to the package's `test` script. */
16
+ testCmd?: string;
17
+ /** Stop after this many mutants (default 100). */
18
+ maxMutants?: number;
19
+ /** Per-run timeout; default derived from the baseline run. */
20
+ timeoutMs?: number;
21
+ onProgress?: (done: number, total: number, result: MutantResult) => void;
22
+ }
23
+ export interface MutationReport {
24
+ target: string;
25
+ root: string;
26
+ command: string;
27
+ relatedTests: string[];
28
+ baselineMs: number;
29
+ generated: number;
30
+ tested: number;
31
+ killed: number;
32
+ survived: number;
33
+ timedOut: number;
34
+ /** 0-100, (killed + timeout) / tested. */
35
+ score: number;
36
+ mutants: MutantResult[];
37
+ }
38
+ export declare class MutationError extends Error {
39
+ output: string;
40
+ constructor(message: string, output?: string);
41
+ }
42
+ /**
43
+ * Test files that import `target` directly or through other local modules
44
+ * (`test -> Page.jsx -> lib/format.js`). Direct-import-only selection misses
45
+ * tests that exercise the target through its callers, which shows up as
46
+ * false survivors.
47
+ */
48
+ export declare function findRelatedTests(root: string, target: string, adapter: LanguageAdapter): string[];
49
+ /** Kill every in-flight test run (and its children). Used on Ctrl-C/SIGTERM before removing the copy. */
50
+ export declare function killRunningTests(): void;
51
+ /**
52
+ * Mutation-test one source file.
53
+ *
54
+ * Safety: the project is copied to a fresh temp directory (node_modules is
55
+ * symlinked, never copied or written by us). Mutants are written only inside
56
+ * the copy, restored after every run, and the copy is deleted at the end —
57
+ * including on Ctrl-C. Your working tree is never modified.
58
+ */
59
+ export declare function mutate(opts: MutateOptions): Promise<MutationReport>;