@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.
- package/dist/gates/deprecated-apis.d.ts +55 -0
- package/dist/gates/deprecated-apis.js +724 -0
- package/dist/gates/deprecated-apis.test.d.ts +1 -0
- package/dist/gates/deprecated-apis.test.js +288 -0
- package/dist/gates/phantom-apis.d.ts +77 -0
- package/dist/gates/phantom-apis.js +675 -0
- package/dist/gates/phantom-apis.test.d.ts +1 -0
- package/dist/gates/phantom-apis.test.js +320 -0
- package/dist/gates/runner.js +13 -0
- package/dist/gates/test-quality.d.ts +67 -0
- package/dist/gates/test-quality.js +512 -0
- package/dist/gates/test-quality.test.d.ts +1 -0
- package/dist/gates/test-quality.test.js +312 -0
- package/dist/templates/index.js +31 -1
- package/dist/types/index.d.ts +348 -0
- package/dist/types/index.js +33 -0
- package/package.json +1 -1
|
@@ -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 {};
|