@rigour-labs/core 3.0.3 → 3.0.4

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.
@@ -0,0 +1,512 @@
1
+ /**
2
+ * AI Test Quality Gate
3
+ *
4
+ * Detects AI-generated test anti-patterns that create false confidence.
5
+ * AI models generate tests that look comprehensive but actually test
6
+ * the AI's own assumptions rather than the developer's intent.
7
+ *
8
+ * Detected anti-patterns:
9
+ * 1. Empty test bodies — tests with no assertions
10
+ * 2. Tautological assertions — expect(true).toBe(true), assert True
11
+ * 3. Mock-everything — tests that mock every dependency (test nothing real)
12
+ * 4. Missing error path tests — only happy path tested
13
+ * 5. Shallow snapshot abuse — snapshot tests with no semantic assertions
14
+ * 6. Assertion-free async — async tests that never await/assert
15
+ *
16
+ * Supported test frameworks:
17
+ * JS/TS — Jest, Vitest, Mocha, Jasmine, Node test runner
18
+ * Python — pytest, unittest
19
+ * Go — testing package (t.Run, table-driven tests)
20
+ * Java — JUnit 4/5, TestNG
21
+ * Kotlin — JUnit 5, kotlin.test
22
+ *
23
+ * @since v3.0.0
24
+ * @since v3.0.3 — Go, Java, Kotlin support added
25
+ */
26
+ import { Gate } from './base.js';
27
+ import { FileScanner } from '../utils/scanner.js';
28
+ import { Logger } from '../utils/logger.js';
29
+ import fs from 'fs-extra';
30
+ import path from 'path';
31
+ export class TestQualityGate extends Gate {
32
+ config;
33
+ constructor(config = {}) {
34
+ super('test-quality', 'AI Test Quality Detection');
35
+ this.config = {
36
+ enabled: config.enabled ?? true,
37
+ check_empty_tests: config.check_empty_tests ?? true,
38
+ check_tautological: config.check_tautological ?? true,
39
+ check_mock_heavy: config.check_mock_heavy ?? true,
40
+ check_snapshot_abuse: config.check_snapshot_abuse ?? true,
41
+ check_assertion_free_async: config.check_assertion_free_async ?? true,
42
+ max_mocks_per_test: config.max_mocks_per_test ?? 5,
43
+ ignore_patterns: config.ignore_patterns ?? [],
44
+ };
45
+ }
46
+ get provenance() { return 'ai-drift'; }
47
+ async run(context) {
48
+ if (!this.config.enabled)
49
+ return [];
50
+ const failures = [];
51
+ const issues = [];
52
+ const files = await FileScanner.findFiles({
53
+ cwd: context.cwd,
54
+ patterns: [
55
+ '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}',
56
+ '**/__tests__/**/*.{ts,js,tsx,jsx}',
57
+ '**/test_*.py', '**/*_test.py', '**/tests/**/*.py',
58
+ '**/*_test.go',
59
+ '**/*Test.java', '**/*Tests.java', '**/src/test/**/*.java',
60
+ '**/*Test.kt', '**/*Tests.kt', '**/src/test/**/*.kt',
61
+ ],
62
+ ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
63
+ '**/.venv/**', '**/venv/**', '**/vendor/**',
64
+ '**/target/**', '**/.gradle/**', '**/out/**'],
65
+ });
66
+ Logger.info(`Test Quality: Scanning ${files.length} test files`);
67
+ for (const file of files) {
68
+ try {
69
+ const fullPath = path.join(context.cwd, file);
70
+ const content = await fs.readFile(fullPath, 'utf-8');
71
+ const ext = path.extname(file);
72
+ if (['.ts', '.js', '.tsx', '.jsx'].includes(ext)) {
73
+ this.checkJSTestQuality(content, file, issues);
74
+ }
75
+ else if (ext === '.py') {
76
+ this.checkPythonTestQuality(content, file, issues);
77
+ }
78
+ else if (ext === '.go') {
79
+ this.checkGoTestQuality(content, file, issues);
80
+ }
81
+ else if (ext === '.java' || ext === '.kt') {
82
+ this.checkJavaKotlinTestQuality(content, file, ext, issues);
83
+ }
84
+ }
85
+ catch { /* skip */ }
86
+ }
87
+ // Group by file
88
+ const byFile = new Map();
89
+ for (const issue of issues) {
90
+ const existing = byFile.get(issue.file) || [];
91
+ existing.push(issue);
92
+ byFile.set(issue.file, existing);
93
+ }
94
+ for (const [file, fileIssues] of byFile) {
95
+ const details = fileIssues.map(i => ` L${i.line}: [${i.pattern}] ${i.reason}`).join('\n');
96
+ failures.push(this.createFailure(`AI test quality issues in ${file}:\n${details}`, [file], `These test patterns indicate AI-generated tests that may not verify actual behavior. Review each test to ensure it validates real business logic, not just AI assumptions.`, 'AI Test Quality', fileIssues[0].line, undefined, 'medium'));
97
+ }
98
+ return failures;
99
+ }
100
+ checkJSTestQuality(content, file, issues) {
101
+ const lines = content.split('\n');
102
+ // Track test blocks for analysis
103
+ let inTestBlock = false;
104
+ let testStartLine = 0;
105
+ let braceDepth = 0;
106
+ let testBlockContent = '';
107
+ let mockCount = 0;
108
+ let hasAssertion = false;
109
+ let hasAwait = false;
110
+ let isAsync = false;
111
+ for (let i = 0; i < lines.length; i++) {
112
+ const line = lines[i];
113
+ const trimmed = line.trim();
114
+ // Detect test block start: it('...', () => { or test('...', async () => {
115
+ const testStart = trimmed.match(/^(?:it|test)\s*\(\s*['"`].*['"`]\s*,\s*(async\s+)?(?:\(\s*\)|function\s*\(\s*\)|\(\s*\{[^}]*\}\s*\))\s*(?:=>)?\s*\{/);
116
+ if (testStart && !inTestBlock) {
117
+ inTestBlock = true;
118
+ testStartLine = i + 1;
119
+ braceDepth = 1;
120
+ testBlockContent = '';
121
+ mockCount = 0;
122
+ hasAssertion = false;
123
+ hasAwait = false;
124
+ isAsync = !!testStart[1];
125
+ // Count opening braces on this line beyond the first
126
+ for (let j = line.indexOf('{') + 1; j < line.length; j++) {
127
+ if (line[j] === '{')
128
+ braceDepth++;
129
+ if (line[j] === '}')
130
+ braceDepth--;
131
+ }
132
+ if (braceDepth === 0) {
133
+ // Single-line test — check immediately
134
+ this.analyzeJSTestBlock(line, file, testStartLine, mockCount, hasAssertion, hasAwait, isAsync, issues);
135
+ inTestBlock = false;
136
+ }
137
+ continue;
138
+ }
139
+ if (inTestBlock) {
140
+ testBlockContent += line + '\n';
141
+ // Track braces
142
+ for (const ch of line) {
143
+ if (ch === '{')
144
+ braceDepth++;
145
+ if (ch === '}')
146
+ braceDepth--;
147
+ }
148
+ // Check for assertions
149
+ if (/expect\s*\(/.test(line) || /assert\s*[.(]/.test(line) ||
150
+ /\.toEqual|\.toBe|\.toContain|\.toMatch|\.toThrow|\.toHaveBeenCalled|\.toHaveLength|\.toBeTruthy|\.toBeFalsy|\.toBeDefined|\.toBeNull|\.toBeUndefined|\.toBeGreaterThan|\.toBeLessThan|\.toHaveProperty|\.toStrictEqual|\.rejects|\.resolves/.test(line)) {
151
+ hasAssertion = true;
152
+ }
153
+ // Check for mocks
154
+ if (/jest\.fn\(|vi\.fn\(|jest\.mock\(|vi\.mock\(|jest\.spyOn\(|vi\.spyOn\(|sinon\.(stub|mock|spy)\(/.test(line)) {
155
+ mockCount++;
156
+ }
157
+ // Check for await
158
+ if (/\bawait\b/.test(line)) {
159
+ hasAwait = true;
160
+ }
161
+ // Check for tautological assertions
162
+ if (this.config.check_tautological) {
163
+ if (/expect\s*\(\s*true\s*\)\s*\.toBe\s*\(\s*true\s*\)/.test(line) ||
164
+ /expect\s*\(\s*false\s*\)\s*\.toBe\s*\(\s*false\s*\)/.test(line) ||
165
+ /expect\s*\(\s*1\s*\)\s*\.toBe\s*\(\s*1\s*\)/.test(line) ||
166
+ /expect\s*\(\s*['"].*['"]\s*\)\s*\.toBe\s*\(\s*['"].*['"]\s*\)/.test(line) && line.match(/expect\s*\(\s*(['"].*?['"])\s*\)\s*\.toBe\s*\(\s*\1\s*\)/)) {
167
+ issues.push({
168
+ file, line: i + 1, pattern: 'tautological-assertion',
169
+ reason: 'Tautological assertion — comparing a literal to itself proves nothing',
170
+ });
171
+ }
172
+ }
173
+ // Check for snapshot-only tests
174
+ if (this.config.check_snapshot_abuse) {
175
+ if (/\.toMatchSnapshot\s*\(/.test(line) || /\.toMatchInlineSnapshot\s*\(/.test(line)) {
176
+ // This is fine IF there are also semantic assertions
177
+ // We'll check when the block ends
178
+ }
179
+ }
180
+ // End of test block
181
+ if (braceDepth === 0) {
182
+ this.analyzeJSTestBlock(testBlockContent, file, testStartLine, mockCount, hasAssertion, hasAwait, isAsync, issues);
183
+ inTestBlock = false;
184
+ }
185
+ }
186
+ }
187
+ }
188
+ analyzeJSTestBlock(content, file, startLine, mockCount, hasAssertion, hasAwait, isAsync, issues) {
189
+ const trimmedContent = content.trim();
190
+ const lines = trimmedContent.split('\n').filter(l => l.trim() && !l.trim().startsWith('//'));
191
+ // Empty test body
192
+ if (this.config.check_empty_tests && (lines.length <= 1 || !hasAssertion)) {
193
+ // Check if it's truly empty (just braces) or has no assertions
194
+ const hasAnyCode = lines.some(l => {
195
+ const t = l.trim();
196
+ return t && t !== '{' && t !== '}' && t !== '});' && !t.startsWith('//');
197
+ });
198
+ if (!hasAnyCode) {
199
+ issues.push({
200
+ file, line: startLine, pattern: 'empty-test',
201
+ reason: 'Empty test body — test does not verify any behavior',
202
+ });
203
+ }
204
+ else if (!hasAssertion) {
205
+ issues.push({
206
+ file, line: startLine, pattern: 'no-assertion',
207
+ reason: 'Test has no assertions — executes code but never verifies results',
208
+ });
209
+ }
210
+ }
211
+ // Mock-heavy test
212
+ if (this.config.check_mock_heavy && mockCount > this.config.max_mocks_per_test) {
213
+ issues.push({
214
+ file, line: startLine, pattern: 'mock-heavy',
215
+ reason: `Test uses ${mockCount} mocks (max: ${this.config.max_mocks_per_test}) — may be testing mocks instead of real behavior`,
216
+ });
217
+ }
218
+ // Async without await
219
+ if (this.config.check_assertion_free_async && isAsync && !hasAwait && hasAssertion) {
220
+ issues.push({
221
+ file, line: startLine, pattern: 'async-no-await',
222
+ reason: 'Async test never uses await — promises may not be resolved before assertions',
223
+ });
224
+ }
225
+ }
226
+ checkPythonTestQuality(content, file, issues) {
227
+ const lines = content.split('\n');
228
+ let inTestFunc = false;
229
+ let testStartLine = 0;
230
+ let testIndent = 0;
231
+ let hasAssertion = false;
232
+ let mockCount = 0;
233
+ let testContent = '';
234
+ for (let i = 0; i < lines.length; i++) {
235
+ const line = lines[i];
236
+ const trimmed = line.trim();
237
+ // Detect test function start
238
+ const testFuncMatch = line.match(/^(\s*)(?:def|async\s+def)\s+(test_\w+)\s*\(/);
239
+ if (testFuncMatch) {
240
+ // If we were in a previous test, analyze it
241
+ if (inTestFunc) {
242
+ this.analyzePythonTestBlock(testContent, file, testStartLine, hasAssertion, mockCount, issues);
243
+ }
244
+ inTestFunc = true;
245
+ testStartLine = i + 1;
246
+ testIndent = testFuncMatch[1].length;
247
+ hasAssertion = false;
248
+ mockCount = 0;
249
+ testContent = '';
250
+ continue;
251
+ }
252
+ if (inTestFunc) {
253
+ // Check if we've left the function (non-empty line at same or lower indent)
254
+ if (trimmed && !line.match(/^\s/) && testIndent === 0) {
255
+ this.analyzePythonTestBlock(testContent, file, testStartLine, hasAssertion, mockCount, issues);
256
+ inTestFunc = false;
257
+ continue;
258
+ }
259
+ if (trimmed && line.match(/^\s+/) && line.search(/\S/) <= testIndent && !trimmed.startsWith('#')) {
260
+ // Non-empty line at or below function indent = function ended
261
+ // But only if not a decorator or continuation
262
+ if (!trimmed.startsWith('@') && !trimmed.startsWith(')') && !trimmed.startsWith(']')) {
263
+ this.analyzePythonTestBlock(testContent, file, testStartLine, hasAssertion, mockCount, issues);
264
+ inTestFunc = false;
265
+ i--; // Re-process this line
266
+ continue;
267
+ }
268
+ }
269
+ testContent += line + '\n';
270
+ // Check for assertions
271
+ if (/\bassert\s+/.test(trimmed) || /self\.assert\w+\s*\(/.test(trimmed) ||
272
+ /pytest\.raises\s*\(/.test(trimmed) || /\.assert_called|\.assert_any_call/.test(trimmed)) {
273
+ hasAssertion = true;
274
+ }
275
+ // Check for mocks
276
+ if (/mock\.|Mock\(|patch\(|MagicMock\(/.test(trimmed)) {
277
+ mockCount++;
278
+ }
279
+ // Tautological assertions
280
+ if (this.config.check_tautological) {
281
+ if (/\bassert\s+True\s*$/.test(trimmed) || /\bassert\s+1\s*==\s*1/.test(trimmed) ||
282
+ /self\.assertTrue\s*\(\s*True\s*\)/.test(trimmed) ||
283
+ /self\.assertEqual\s*\(\s*(\d+|['"][^'"]*['"])\s*,\s*\1\s*\)/.test(trimmed)) {
284
+ issues.push({
285
+ file, line: i + 1, pattern: 'tautological-assertion',
286
+ reason: 'Tautological assertion — comparing a constant to itself proves nothing',
287
+ });
288
+ }
289
+ }
290
+ }
291
+ }
292
+ // Handle last test function
293
+ if (inTestFunc) {
294
+ this.analyzePythonTestBlock(testContent, file, testStartLine, hasAssertion, mockCount, issues);
295
+ }
296
+ }
297
+ analyzePythonTestBlock(content, file, startLine, hasAssertion, mockCount, issues) {
298
+ const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('#'));
299
+ // Empty test (only pass or docstring)
300
+ if (this.config.check_empty_tests) {
301
+ const meaningfulLines = lines.filter(l => {
302
+ const t = l.trim();
303
+ return t && t !== 'pass' && !t.startsWith('"""') && !t.startsWith("'''") && !t.startsWith('#');
304
+ });
305
+ if (meaningfulLines.length === 0) {
306
+ issues.push({
307
+ file, line: startLine, pattern: 'empty-test',
308
+ reason: 'Empty test function — contains only pass or docstring',
309
+ });
310
+ return;
311
+ }
312
+ }
313
+ // No assertions
314
+ if (this.config.check_empty_tests && !hasAssertion && lines.length > 0) {
315
+ issues.push({
316
+ file, line: startLine, pattern: 'no-assertion',
317
+ reason: 'Test has no assertions — executes code but never verifies results',
318
+ });
319
+ }
320
+ // Mock-heavy
321
+ if (this.config.check_mock_heavy && mockCount > this.config.max_mocks_per_test) {
322
+ issues.push({
323
+ file, line: startLine, pattern: 'mock-heavy',
324
+ reason: `Test uses ${mockCount} mocks (max: ${this.config.max_mocks_per_test}) — may be testing mocks, not real behavior`,
325
+ });
326
+ }
327
+ }
328
+ /**
329
+ * Go test quality checks.
330
+ * Go tests use func TestXxx(t *testing.T) pattern.
331
+ * Assertions via t.Fatal, t.Error, t.Fatalf, t.Errorf, t.Fail, t.FailNow.
332
+ * Also checks for t.Run subtests and table-driven patterns.
333
+ */
334
+ checkGoTestQuality(content, file, issues) {
335
+ const lines = content.split('\n');
336
+ let inTestFunc = false;
337
+ let testStartLine = 0;
338
+ let braceDepth = 0;
339
+ let hasAssertion = false;
340
+ let testContent = '';
341
+ for (let i = 0; i < lines.length; i++) {
342
+ const line = lines[i];
343
+ const trimmed = line.trim();
344
+ // Detect test function: func TestXxx(t *testing.T) {
345
+ const testMatch = trimmed.match(/^func\s+(Test\w+|Benchmark\w+)\s*\(/);
346
+ if (testMatch && !inTestFunc) {
347
+ inTestFunc = true;
348
+ testStartLine = i + 1;
349
+ braceDepth = 0;
350
+ hasAssertion = false;
351
+ testContent = '';
352
+ // Count braces on this line
353
+ for (const ch of line) {
354
+ if (ch === '{')
355
+ braceDepth++;
356
+ if (ch === '}')
357
+ braceDepth--;
358
+ }
359
+ continue;
360
+ }
361
+ if (inTestFunc) {
362
+ testContent += line + '\n';
363
+ for (const ch of line) {
364
+ if (ch === '{')
365
+ braceDepth++;
366
+ if (ch === '}')
367
+ braceDepth--;
368
+ }
369
+ // Go test assertions: t.Fatal, t.Error, t.Fatalf, t.Errorf, t.Fail, t.FailNow
370
+ // Also: assert/require from testify, t.Run for subtests
371
+ if (/\bt\.\s*(?:Fatal|Error|Fatalf|Errorf|Fail|FailNow|Log|Logf|Skip|Skipf|Helper)\s*\(/.test(line) ||
372
+ /\bt\.Run\s*\(/.test(line) ||
373
+ /\bassert\.\w+\s*\(/.test(line) || /\brequire\.\w+\s*\(/.test(line) ||
374
+ /\bif\b.*\bt\./.test(line)) {
375
+ hasAssertion = true;
376
+ }
377
+ // Tautological: if true { t.Fatal... } or assert.True(t, true)
378
+ if (this.config.check_tautological) {
379
+ if (/assert\.True\s*\(\s*\w+\s*,\s*true\s*\)/.test(line) ||
380
+ /assert\.Equal\s*\(\s*\w+\s*,\s*(\d+|"[^"]*")\s*,\s*\1\s*\)/.test(line)) {
381
+ issues.push({
382
+ file, line: i + 1, pattern: 'tautological-assertion',
383
+ reason: 'Tautological assertion — comparing a constant to itself proves nothing',
384
+ });
385
+ }
386
+ }
387
+ // End of function
388
+ if (braceDepth === 0 && testContent.trim()) {
389
+ const meaningful = testContent.split('\n').filter(l => {
390
+ const t = l.trim();
391
+ return t && t !== '{' && t !== '}' && !t.startsWith('//');
392
+ });
393
+ if (this.config.check_empty_tests && meaningful.length === 0) {
394
+ issues.push({
395
+ file, line: testStartLine, pattern: 'empty-test',
396
+ reason: 'Empty test function — no test logic',
397
+ });
398
+ }
399
+ else if (this.config.check_empty_tests && !hasAssertion && meaningful.length > 0) {
400
+ issues.push({
401
+ file, line: testStartLine, pattern: 'no-assertion',
402
+ reason: 'Test has no assertions (t.Error, t.Fatal, assert.*) — executes code but never verifies',
403
+ });
404
+ }
405
+ inTestFunc = false;
406
+ }
407
+ }
408
+ }
409
+ }
410
+ /**
411
+ * Java/Kotlin test quality checks.
412
+ * JUnit 4: @Test + Assert.assertEquals, assertTrue, etc.
413
+ * JUnit 5: @Test + Assertions.assertEquals, assertThrows, etc.
414
+ * Kotlin: @Test + kotlin.test assertEquals, etc.
415
+ */
416
+ checkJavaKotlinTestQuality(content, file, ext, issues) {
417
+ const lines = content.split('\n');
418
+ const isKotlin = ext === '.kt';
419
+ let inTestMethod = false;
420
+ let testStartLine = 0;
421
+ let braceDepth = 0;
422
+ let hasAssertion = false;
423
+ let mockCount = 0;
424
+ for (let i = 0; i < lines.length; i++) {
425
+ const line = lines[i];
426
+ const trimmed = line.trim();
427
+ // Detect @Test annotation (next non-empty line is the method)
428
+ if (trimmed === '@Test' || /^@Test\s*(\(|$)/.test(trimmed)) {
429
+ // Look for the method signature on this or next lines
430
+ for (let j = i + 1; j < Math.min(i + 3, lines.length); j++) {
431
+ const methodLine = lines[j].trim();
432
+ const methodMatch = isKotlin
433
+ ? methodLine.match(/^(?:fun|suspend\s+fun)\s+(\w+)\s*\(/)
434
+ : methodLine.match(/^(?:public\s+|private\s+|protected\s+)?(?:static\s+)?void\s+(\w+)\s*\(/);
435
+ if (methodMatch) {
436
+ inTestMethod = true;
437
+ testStartLine = j + 1;
438
+ braceDepth = 0;
439
+ hasAssertion = false;
440
+ mockCount = 0;
441
+ // Count braces
442
+ for (const ch of lines[j]) {
443
+ if (ch === '{')
444
+ braceDepth++;
445
+ if (ch === '}')
446
+ braceDepth--;
447
+ }
448
+ i = j;
449
+ break;
450
+ }
451
+ }
452
+ continue;
453
+ }
454
+ if (inTestMethod) {
455
+ for (const ch of line) {
456
+ if (ch === '{')
457
+ braceDepth++;
458
+ if (ch === '}')
459
+ braceDepth--;
460
+ }
461
+ // JUnit 4/5 assertions
462
+ if (/\b(?:assert(?:Equals|True|False|NotNull|Null|That|Throws|DoesNotThrow|Same|NotSame|ArrayEquals)|assertEquals|assertTrue|assertFalse|assertNotNull|assertNull|assertThrows)\s*\(/.test(line)) {
463
+ hasAssertion = true;
464
+ }
465
+ // Kotlin test assertions
466
+ if (isKotlin && /\b(?:assertEquals|assertTrue|assertFalse|assertNotNull|assertNull|assertFailsWith|assertIs|assertContains|expect)\s*[({]/.test(line)) {
467
+ hasAssertion = true;
468
+ }
469
+ // Hamcrest / AssertJ
470
+ if (/\bassertThat\s*\(/.test(line) || /\.should\w*\(/.test(line)) {
471
+ hasAssertion = true;
472
+ }
473
+ // Verify (Mockito)
474
+ if (/\bverify\s*\(/.test(line)) {
475
+ hasAssertion = true;
476
+ }
477
+ // Mock counting
478
+ if (/\b(?:mock|spy|when|doReturn|doThrow|doNothing|Mockito\.\w+)\s*\(/.test(line) ||
479
+ /@Mock\b/.test(line) || /@InjectMocks\b/.test(line)) {
480
+ mockCount++;
481
+ }
482
+ // Tautological
483
+ if (this.config.check_tautological) {
484
+ if (/assertEquals\s*\(\s*true\s*,\s*true\s*\)/.test(line) ||
485
+ /assertTrue\s*\(\s*true\s*\)/.test(line) ||
486
+ /assertEquals\s*\(\s*(\d+)\s*,\s*\1\s*\)/.test(line)) {
487
+ issues.push({
488
+ file, line: i + 1, pattern: 'tautological-assertion',
489
+ reason: 'Tautological assertion — comparing a constant to itself proves nothing',
490
+ });
491
+ }
492
+ }
493
+ // End of method
494
+ if (braceDepth === 0) {
495
+ if (this.config.check_empty_tests && !hasAssertion) {
496
+ issues.push({
497
+ file, line: testStartLine, pattern: 'no-assertion',
498
+ reason: 'Test has no assertions — executes code but never verifies results',
499
+ });
500
+ }
501
+ if (this.config.check_mock_heavy && mockCount > this.config.max_mocks_per_test) {
502
+ issues.push({
503
+ file, line: testStartLine, pattern: 'mock-heavy',
504
+ reason: `Test uses ${mockCount} mocks (max: ${this.config.max_mocks_per_test}) — may be testing mocks, not real behavior`,
505
+ });
506
+ }
507
+ inTestMethod = false;
508
+ }
509
+ }
510
+ }
511
+ }
512
+ }
@@ -0,0 +1 @@
1
+ export {};