@sun-asterisk/sunlint 1.3.7 → 1.3.9
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/CHANGELOG.md +63 -0
- package/config/defaults/default.json +2 -1
- package/config/rule-analysis-strategies.js +20 -0
- package/config/rules/enhanced-rules-registry.json +247 -53
- package/core/file-targeting-service.js +98 -7
- package/package.json +1 -1
- package/rules/common/C065_one_behavior_per_test/analyzer.js +851 -0
- package/rules/common/C065_one_behavior_per_test/config.json +95 -0
- package/rules/security/S020_no_eval_dynamic_code/README.md +136 -0
- package/rules/security/S020_no_eval_dynamic_code/analyzer.js +263 -0
- package/rules/security/S020_no_eval_dynamic_code/config.json +54 -0
- package/rules/security/S020_no_eval_dynamic_code/regex-based-analyzer.js +307 -0
- package/rules/security/S020_no_eval_dynamic_code/symbol-based-analyzer.js +280 -0
- package/rules/security/S024_xpath_xxe_protection/symbol-based-analyzer.js +3 -3
- package/rules/security/S025_server_side_validation/symbol-based-analyzer.js +3 -4
- package/rules/security/S030_directory_browsing_protection/README.md +128 -0
- package/rules/security/S030_directory_browsing_protection/analyzer.js +264 -0
- package/rules/security/S030_directory_browsing_protection/config.json +63 -0
- package/rules/security/S030_directory_browsing_protection/regex-based-analyzer.js +483 -0
- package/rules/security/S030_directory_browsing_protection/symbol-based-analyzer.js +539 -0
- package/rules/security/S033_samesite_session_cookies/symbol-based-analyzer.js +8 -9
- package/rules/security/S037_cache_headers/README.md +128 -0
- package/rules/security/S037_cache_headers/analyzer.js +263 -0
- package/rules/security/S037_cache_headers/config.json +50 -0
- package/rules/security/S037_cache_headers/regex-based-analyzer.js +463 -0
- package/rules/security/S037_cache_headers/symbol-based-analyzer.js +546 -0
- package/rules/security/S038_no_version_headers/README.md +234 -0
- package/rules/security/S038_no_version_headers/analyzer.js +262 -0
- package/rules/security/S038_no_version_headers/config.json +49 -0
- package/rules/security/S038_no_version_headers/regex-based-analyzer.js +339 -0
- package/rules/security/S038_no_version_headers/symbol-based-analyzer.js +375 -0
- package/rules/security/S039_no_session_tokens_in_url/README.md +198 -0
- package/rules/security/S039_no_session_tokens_in_url/analyzer.js +262 -0
- package/rules/security/S039_no_session_tokens_in_url/config.json +92 -0
- package/rules/security/S039_no_session_tokens_in_url/regex-based-analyzer.js +337 -0
- package/rules/security/S039_no_session_tokens_in_url/symbol-based-analyzer.js +443 -0
- package/rules/security/S049_short_validity_tokens/analyzer.js +175 -0
- package/rules/security/S049_short_validity_tokens/config.json +124 -0
- package/rules/security/S049_short_validity_tokens/regex-based-analyzer.js +295 -0
- package/rules/security/S049_short_validity_tokens/symbol-based-analyzer.js +389 -0
- package/rules/security/S051_password_length_policy/analyzer.js +410 -0
- package/rules/security/S051_password_length_policy/config.json +83 -0
- package/rules/security/S052_weak_otp_entropy/analyzer.js +403 -0
- package/rules/security/S052_weak_otp_entropy/config.json +57 -0
- package/rules/security/S054_no_default_accounts/README.md +129 -0
- package/rules/security/S054_no_default_accounts/analyzer.js +792 -0
- package/rules/security/S054_no_default_accounts/config.json +101 -0
- package/rules/security/S056_log_injection_protection/analyzer.js +242 -0
- package/rules/security/S056_log_injection_protection/config.json +148 -0
- package/rules/security/S056_log_injection_protection/regex-based-analyzer.js +120 -0
- package/rules/security/S056_log_injection_protection/symbol-based-analyzer.js +246 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* C065: One Behavior per Test (AAA Pattern)
|
|
6
|
+
*
|
|
7
|
+
* Detects:
|
|
8
|
+
* 1. Multiple independent assertions in single test (Level 1 heuristic)
|
|
9
|
+
* 2. Multiple "Act" operations in single test
|
|
10
|
+
* 3. Control flow statements (if/for/switch) in test methods
|
|
11
|
+
* 4. Unrelated expectations for different SUTs (Level 2 AST)
|
|
12
|
+
*
|
|
13
|
+
* Uses hybrid approach: Heuristic patterns + AST context analysis for accuracy
|
|
14
|
+
*/
|
|
15
|
+
class C065OneBehaviorPerTestAnalyzer {
|
|
16
|
+
constructor(config = null) {
|
|
17
|
+
this.ruleId = 'C065';
|
|
18
|
+
this.loadConfig(config);
|
|
19
|
+
|
|
20
|
+
// Compile regex patterns for performance
|
|
21
|
+
this.compiledPatterns = this.compilePatterns();
|
|
22
|
+
this.verbose = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
loadConfig(config) {
|
|
26
|
+
try {
|
|
27
|
+
if (config && config.options) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.assertApis = config.options.assertApis || {};
|
|
30
|
+
this.actHeuristics = config.options.actHeuristics || {};
|
|
31
|
+
this.controlFlow = config.options.controlFlow || [];
|
|
32
|
+
this.testPatterns = config.options.testPatterns || {};
|
|
33
|
+
this.parameterizedHints = config.options.parameterizedHints || [];
|
|
34
|
+
this.thresholds = config.options.thresholds || {};
|
|
35
|
+
this.flags = config.options.flags || {};
|
|
36
|
+
this.whitelist = config.options.whitelist || {};
|
|
37
|
+
this.allowlist = config.options.allowlist || { paths: [] };
|
|
38
|
+
} else {
|
|
39
|
+
// Load from config file
|
|
40
|
+
const configPath = path.join(__dirname, 'config.json');
|
|
41
|
+
const configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
42
|
+
this.loadConfig(configData);
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.warn(`[C065] Failed to load config: ${error.message}`);
|
|
46
|
+
this.initializeDefaultConfig();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
initializeDefaultConfig() {
|
|
51
|
+
this.assertApis = {
|
|
52
|
+
javascript: ['expect\\(', 'assert\\.', 'should\\.'],
|
|
53
|
+
typescript: ['expect\\(', 'assert\\.', 'should\\.'],
|
|
54
|
+
java: ['assertThat\\(', 'assertEquals\\(', 'assertTrue\\(']
|
|
55
|
+
};
|
|
56
|
+
this.actHeuristics = {
|
|
57
|
+
common: ['sut\\.', '\\.execute\\(', '\\.handle\\(', '\\.run\\(', '\\.call\\(']
|
|
58
|
+
};
|
|
59
|
+
this.controlFlow = ['\\bif\\b', '\\bswitch\\b', '\\bfor\\b', '\\bwhile\\b'];
|
|
60
|
+
this.testPatterns = {
|
|
61
|
+
javascript: ['\\bit\\s*\\(', '\\btest\\s*\\('],
|
|
62
|
+
java: ['@Test']
|
|
63
|
+
};
|
|
64
|
+
this.thresholds = {
|
|
65
|
+
maxActsPerTest: 1,
|
|
66
|
+
maxUnrelatedExpects: 2,
|
|
67
|
+
maxControlFlowStatements: 0
|
|
68
|
+
};
|
|
69
|
+
this.flags = {
|
|
70
|
+
flagControlFlowInTest: true,
|
|
71
|
+
treatSnapshotAsSingleAssert: true,
|
|
72
|
+
allowMultipleAssertsForSameObject: true
|
|
73
|
+
};
|
|
74
|
+
this.allowlist = {
|
|
75
|
+
paths: ['test/', 'tests/', '__tests__/', 'spec/', 'specs/'],
|
|
76
|
+
filePatterns: ['\\.test\\.', '\\.spec\\.']
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
compilePatterns() {
|
|
81
|
+
const patterns = {
|
|
82
|
+
assertApis: {},
|
|
83
|
+
actHeuristics: {},
|
|
84
|
+
controlFlow: [],
|
|
85
|
+
testPatterns: {},
|
|
86
|
+
testFiles: []
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Compile assert API patterns by language
|
|
90
|
+
for (const [lang, regexes] of Object.entries(this.assertApis)) {
|
|
91
|
+
patterns.assertApis[lang] = regexes.map(regex => new RegExp(regex, 'gi'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Compile act heuristic patterns
|
|
95
|
+
for (const [lang, regexes] of Object.entries(this.actHeuristics)) {
|
|
96
|
+
patterns.actHeuristics[lang] = regexes.map(regex => new RegExp(regex, 'gi'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Compile control flow patterns
|
|
100
|
+
patterns.controlFlow = this.controlFlow.map(regex => new RegExp(regex, 'gi'));
|
|
101
|
+
|
|
102
|
+
// Compile test method patterns
|
|
103
|
+
for (const [lang, regexes] of Object.entries(this.testPatterns)) {
|
|
104
|
+
patterns.testPatterns[lang] = regexes.map(regex => new RegExp(regex, 'gi'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Compile test file patterns
|
|
108
|
+
if (this.allowlist.filePatterns) {
|
|
109
|
+
patterns.testFiles = this.allowlist.filePatterns.map(regex => new RegExp(regex, 'i'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return patterns;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
analyze(files, language, options = {}) {
|
|
116
|
+
this.verbose = options.verbose || false;
|
|
117
|
+
const violations = [];
|
|
118
|
+
|
|
119
|
+
if (this.verbose) {
|
|
120
|
+
console.log(`[DEBUG] 🧪 C065 ANALYZE: Starting test behavior analysis`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!Array.isArray(files)) {
|
|
124
|
+
files = [files];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const filePath of files) {
|
|
128
|
+
if (this.verbose) {
|
|
129
|
+
console.log(`[DEBUG] 🧪 C065: Analyzing ${filePath.split('/').pop()}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
134
|
+
const fileExtension = path.extname(filePath);
|
|
135
|
+
const fileName = path.basename(filePath);
|
|
136
|
+
|
|
137
|
+
// Only analyze test files
|
|
138
|
+
if (this.isTestFile(filePath, fileName)) {
|
|
139
|
+
const fileViolations = this.analyzeFile(filePath, content, fileExtension, fileName);
|
|
140
|
+
violations.push(...fileViolations);
|
|
141
|
+
} else if (this.verbose) {
|
|
142
|
+
console.log(`[DEBUG] 🧪 C065: Skipping non-test file: ${fileName}`);
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.warn(`[C065] Error analyzing ${filePath}: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.verbose) {
|
|
150
|
+
console.log(`[DEBUG] 🧪 C065: Found ${violations.length} test behavior violations`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return violations;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Alias methods for different engines
|
|
157
|
+
run(filePath, content, options = {}) {
|
|
158
|
+
this.verbose = options.verbose || false;
|
|
159
|
+
const fileExtension = path.extname(filePath);
|
|
160
|
+
const fileName = path.basename(filePath);
|
|
161
|
+
return this.analyzeFile(filePath, content, fileExtension, fileName);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
runAnalysis(filePath, content, options = {}) {
|
|
165
|
+
return this.run(filePath, content, options);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
runEnhancedAnalysis(filePath, content, language, options = {}) {
|
|
169
|
+
return this.run(filePath, content, options);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
analyzeFile(filePath, content, fileExtension, fileName) {
|
|
173
|
+
const language = this.detectLanguage(fileExtension, fileName);
|
|
174
|
+
|
|
175
|
+
if (this.verbose) {
|
|
176
|
+
console.log(`[DEBUG] 🧪 C065: Detected language: ${language}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Only analyze test files
|
|
180
|
+
if (!this.isTestFile(filePath, fileName)) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return this.analyzeTestBehaviors(filePath, content, language);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
detectLanguage(fileExtension, fileName) {
|
|
188
|
+
const extensions = {
|
|
189
|
+
'.js': 'javascript',
|
|
190
|
+
'.jsx': 'javascript',
|
|
191
|
+
'.ts': 'typescript',
|
|
192
|
+
'.tsx': 'typescript',
|
|
193
|
+
'.java': 'java',
|
|
194
|
+
'.cs': 'csharp',
|
|
195
|
+
'.swift': 'swift',
|
|
196
|
+
'.kt': 'kotlin',
|
|
197
|
+
'.py': 'python'
|
|
198
|
+
};
|
|
199
|
+
return extensions[fileExtension] || 'generic';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
isTestFile(filePath, fileName) {
|
|
203
|
+
// Check file path for test directories
|
|
204
|
+
const allowedPaths = this.allowlist.paths || [];
|
|
205
|
+
const pathMatch = allowedPaths.some(path => filePath.includes(path));
|
|
206
|
+
|
|
207
|
+
// Check file name patterns
|
|
208
|
+
const filePatternMatch = this.compiledPatterns.testFiles.some(pattern =>
|
|
209
|
+
pattern.test(fileName) || pattern.test(filePath)
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (this.verbose) {
|
|
213
|
+
console.log(`[DEBUG] 🧪 C065 isTestFile: ${fileName}`);
|
|
214
|
+
console.log(`[DEBUG] 🧪 allowedPaths: ${JSON.stringify(allowedPaths)}`);
|
|
215
|
+
console.log(`[DEBUG] 🧪 pathMatch: ${pathMatch}`);
|
|
216
|
+
console.log(`[DEBUG] 🧪 filePatternMatch: ${filePatternMatch}`);
|
|
217
|
+
console.log(`[DEBUG] 🧪 final result: ${pathMatch || filePatternMatch}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return pathMatch || filePatternMatch;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
analyzeTestBehaviors(filePath, content, language) {
|
|
224
|
+
const violations = [];
|
|
225
|
+
const lines = content.split('\n');
|
|
226
|
+
|
|
227
|
+
if (this.verbose) {
|
|
228
|
+
console.log(`[DEBUG] 🧪 C065: Starting test behavior analysis of ${lines.length} lines`);
|
|
229
|
+
console.log(`[DEBUG] 🧪 C065: Language detected: ${language}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Step 1: Extract test methods
|
|
233
|
+
const testMethods = this.extractTestMethods(content, lines, language);
|
|
234
|
+
|
|
235
|
+
if (this.verbose) {
|
|
236
|
+
console.log(`[DEBUG] 🧪 C065: Found ${testMethods.length} test methods`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Step 2: Analyze each test method
|
|
240
|
+
for (const testMethod of testMethods) {
|
|
241
|
+
if (this.verbose) {
|
|
242
|
+
console.log(`[DEBUG] 🧪 C065: Analyzing test method: ${testMethod.name} (lines ${testMethod.startLine}-${testMethod.endLine})`);
|
|
243
|
+
}
|
|
244
|
+
const methodViolations = this.analyzeTestMethod(testMethod, filePath, language);
|
|
245
|
+
if (this.verbose) {
|
|
246
|
+
console.log(`[DEBUG] 🧪 C065: Found ${methodViolations.length} violations in ${testMethod.name}`);
|
|
247
|
+
}
|
|
248
|
+
violations.push(...methodViolations);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (this.verbose) {
|
|
252
|
+
console.log(`[DEBUG] 🧪 C065: Found ${violations.length} behavior violations total`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return violations;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
extractTestMethods(content, lines, language) {
|
|
259
|
+
const testMethods = [];
|
|
260
|
+
const patterns = this.compiledPatterns.testPatterns[language] || [];
|
|
261
|
+
|
|
262
|
+
for (const pattern of patterns) {
|
|
263
|
+
const matches = [...content.matchAll(pattern)];
|
|
264
|
+
for (const match of matches) {
|
|
265
|
+
const startLine = this.getLineNumber(content, match.index);
|
|
266
|
+
const matchText = match[0];
|
|
267
|
+
|
|
268
|
+
// Skip describe blocks - we only want individual test methods
|
|
269
|
+
if (matchText.includes('describe')) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const methodContent = this.extractMethodBody(lines, startLine, language);
|
|
274
|
+
|
|
275
|
+
if (methodContent) {
|
|
276
|
+
testMethods.push({
|
|
277
|
+
name: matchText,
|
|
278
|
+
startLine: startLine,
|
|
279
|
+
endLine: methodContent.endLine,
|
|
280
|
+
content: methodContent.content,
|
|
281
|
+
lines: methodContent.lines
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return testMethods;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
extractMethodBody(lines, startLine, language) {
|
|
291
|
+
// TODO: Implement method body extraction logic
|
|
292
|
+
// For now, return a simple implementation
|
|
293
|
+
const methodLines = [];
|
|
294
|
+
let braceCount = 0;
|
|
295
|
+
let inMethod = false;
|
|
296
|
+
let endLine = startLine;
|
|
297
|
+
|
|
298
|
+
for (let i = startLine - 1; i < lines.length; i++) {
|
|
299
|
+
const line = lines[i];
|
|
300
|
+
|
|
301
|
+
if (line.includes('{')) {
|
|
302
|
+
braceCount += (line.match(/\{/g) || []).length;
|
|
303
|
+
inMethod = true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (inMethod) {
|
|
307
|
+
methodLines.push(line);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (line.includes('}')) {
|
|
311
|
+
braceCount -= (line.match(/\}/g) || []).length;
|
|
312
|
+
if (braceCount <= 0 && inMethod) {
|
|
313
|
+
endLine = i + 1;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
content: methodLines.join('\n'),
|
|
321
|
+
lines: methodLines,
|
|
322
|
+
endLine: endLine
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
analyzeTestMethod(testMethod, filePath, language) {
|
|
327
|
+
const violations = [];
|
|
328
|
+
|
|
329
|
+
if (this.verbose) {
|
|
330
|
+
console.log(`[DEBUG] 🧪 C065: Analyzing test method: ${testMethod.name}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Level 1: Heuristic analysis
|
|
334
|
+
const heuristicViolations = this.analyzeHeuristics(testMethod, filePath, language);
|
|
335
|
+
if (this.verbose) {
|
|
336
|
+
console.log(`[DEBUG] 🧪 C065: Heuristic violations: ${heuristicViolations.length}`);
|
|
337
|
+
}
|
|
338
|
+
violations.push(...heuristicViolations);
|
|
339
|
+
|
|
340
|
+
// Level 2: Context analysis (integrated into countAssertions)
|
|
341
|
+
// const contextViolations = this.analyzeContext(testMethod, filePath, language);
|
|
342
|
+
// console.log(`[DEBUG] 🧪 C065: Context violations: ${contextViolations.length}`);
|
|
343
|
+
// violations.push(...contextViolations);
|
|
344
|
+
|
|
345
|
+
return violations;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
analyzeHeuristics(testMethod, filePath, language) {
|
|
349
|
+
const violations = [];
|
|
350
|
+
const content = testMethod.content;
|
|
351
|
+
|
|
352
|
+
if (this.verbose) {
|
|
353
|
+
console.log(`[DEBUG] 🧪 C065: analyzeHeuristics called for ${testMethod.name}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check 1: Count assertions
|
|
357
|
+
const assertCount = this.countAssertions(content, language);
|
|
358
|
+
|
|
359
|
+
// Check 2: Count acts
|
|
360
|
+
const actCount = this.countActs(content, language);
|
|
361
|
+
|
|
362
|
+
// Check 3: Check control flow
|
|
363
|
+
const controlFlowCount = this.countControlFlow(content);
|
|
364
|
+
|
|
365
|
+
if (this.verbose) {
|
|
366
|
+
console.log(`[DEBUG] 🧪 C065: Method stats - Asserts: ${assertCount}, Acts: ${actCount}, ControlFlow: ${controlFlowCount}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Violation: Multiple unrelated assertions (context-based)
|
|
370
|
+
if (assertCount > this.thresholds.maxUnrelatedExpects) {
|
|
371
|
+
violations.push({
|
|
372
|
+
ruleId: this.ruleId,
|
|
373
|
+
message: `Test has ${assertCount} assertions spanning multiple contexts - tests should verify a single behavior`,
|
|
374
|
+
severity: 'warning',
|
|
375
|
+
line: testMethod.startLine,
|
|
376
|
+
column: 1,
|
|
377
|
+
filePath: filePath,
|
|
378
|
+
context: {
|
|
379
|
+
violationType: 'multiple_behaviors',
|
|
380
|
+
evidence: testMethod.name,
|
|
381
|
+
currentCount: assertCount,
|
|
382
|
+
maxAllowed: this.thresholds.maxUnrelatedExpects,
|
|
383
|
+
recommendation: 'Split this test into separate tests, each focusing on a single behavior/context'
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Violation: Too many acts (with special handling for UI tests)
|
|
389
|
+
const allowedActs = this.isUITestMethod(content) ? 3 : this.thresholds.maxActsPerTest;
|
|
390
|
+
if (actCount > allowedActs) {
|
|
391
|
+
violations.push({
|
|
392
|
+
ruleId: this.ruleId,
|
|
393
|
+
message: `Test has ${actCount} actions but should have max ${allowedActs} (Single Action principle)`,
|
|
394
|
+
severity: 'warning',
|
|
395
|
+
line: testMethod.startLine,
|
|
396
|
+
column: 1,
|
|
397
|
+
filePath: filePath,
|
|
398
|
+
context: {
|
|
399
|
+
violationType: 'multiple_acts',
|
|
400
|
+
evidence: testMethod.name,
|
|
401
|
+
currentCount: actCount,
|
|
402
|
+
maxAllowed: allowedActs,
|
|
403
|
+
recommendation: 'Split this test into multiple tests, each testing a single action/behavior'
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Violation: Control flow in test
|
|
409
|
+
if (this.flags.flagControlFlowInTest && controlFlowCount > this.thresholds.maxControlFlowStatements) {
|
|
410
|
+
violations.push({
|
|
411
|
+
ruleId: this.ruleId,
|
|
412
|
+
message: `Test contains ${controlFlowCount} control flow statements - tests should be linear and predictable`,
|
|
413
|
+
severity: 'warning',
|
|
414
|
+
line: testMethod.startLine,
|
|
415
|
+
column: 1,
|
|
416
|
+
filePath: filePath,
|
|
417
|
+
context: {
|
|
418
|
+
violationType: 'control_flow_in_test',
|
|
419
|
+
evidence: testMethod.name,
|
|
420
|
+
currentCount: controlFlowCount,
|
|
421
|
+
maxAllowed: this.thresholds.maxControlFlowStatements,
|
|
422
|
+
recommendation: 'Remove if/for/switch statements and create separate parameterized tests instead'
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return violations;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
countAssertions(content, language) {
|
|
431
|
+
const patterns = this.compiledPatterns.assertApis[language] || [];
|
|
432
|
+
let count = 0;
|
|
433
|
+
const assertions = [];
|
|
434
|
+
const lines = content.split('\n');
|
|
435
|
+
|
|
436
|
+
if (this.verbose) {
|
|
437
|
+
console.log(`[DEBUG] 🧪 C065: Counting assertions in ${language}, patterns: ${patterns.length}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Extract all assertion lines with context
|
|
441
|
+
for (let i = 0; i < lines.length; i++) {
|
|
442
|
+
const line = lines[i].trim();
|
|
443
|
+
for (const pattern of patterns) {
|
|
444
|
+
pattern.lastIndex = 0;
|
|
445
|
+
if (pattern.test(line)) {
|
|
446
|
+
assertions.push({
|
|
447
|
+
line: line,
|
|
448
|
+
lineNumber: i + 1,
|
|
449
|
+
subject: this.extractAssertionSubject(line)
|
|
450
|
+
});
|
|
451
|
+
count++;
|
|
452
|
+
if (this.verbose) {
|
|
453
|
+
console.log(`[DEBUG] 🧪 C065: Found assertion ${count}: ${line.substring(0, 50)}...`);
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (this.verbose) {
|
|
461
|
+
console.log(`[DEBUG] 🧪 C065: Total assertions found: ${assertions.length}, threshold: ${this.thresholds.maxUnrelatedExpects}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Analyze context similarity if multiple assertions
|
|
465
|
+
if (assertions.length > this.thresholds.maxUnrelatedExpects) {
|
|
466
|
+
const contextGroups = this.groupAssertionsByContext(assertions);
|
|
467
|
+
|
|
468
|
+
if (this.verbose) {
|
|
469
|
+
console.log(`[DEBUG] 🧪 C065: Found ${assertions.length} assertions in ${contextGroups.length} context groups`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// If assertions span multiple unrelated contexts, it's a violation
|
|
473
|
+
if (contextGroups.length > 1) {
|
|
474
|
+
if (this.verbose) {
|
|
475
|
+
console.log(`[DEBUG] 🧪 C065: Multiple contexts detected - returning high count`);
|
|
476
|
+
}
|
|
477
|
+
return assertions.length; // Return high count to trigger violation
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return count;
|
|
482
|
+
} countActs(content, language) {
|
|
483
|
+
if (this.verbose) {
|
|
484
|
+
console.log(`[DEBUG] 🧪 C065: countActs called for ${language}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const commonPatterns = this.compiledPatterns.actHeuristics.common || [];
|
|
488
|
+
const langPatterns = this.compiledPatterns.actHeuristics[language] || [];
|
|
489
|
+
const allPatterns = [...commonPatterns, ...langPatterns];
|
|
490
|
+
|
|
491
|
+
let count = 0;
|
|
492
|
+
const foundActions = new Set(); // To avoid counting same line multiple times
|
|
493
|
+
|
|
494
|
+
// Check if this is a form interaction sequence (allowlist)
|
|
495
|
+
if (this.isFormInteractionSequence(content)) {
|
|
496
|
+
return 1; // Treat form interaction as single behavior
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Check if this is a UI interaction workflow (allowlist)
|
|
500
|
+
if (this.isUIInteractionWorkflow(content)) {
|
|
501
|
+
if (this.verbose) {
|
|
502
|
+
console.log(`[DEBUG] 🧪 C065: UI workflow detected - treating as single behavior`);
|
|
503
|
+
}
|
|
504
|
+
return 1; // Treat UI workflow as single behavior
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (this.verbose) {
|
|
508
|
+
console.log(`[DEBUG] 🧪 C065: No UI workflow detected, proceeding with normal counting`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const lines = content.split('\n');
|
|
512
|
+
|
|
513
|
+
for (let i = 0; i < lines.length; i++) {
|
|
514
|
+
const line = lines[i].trim();
|
|
515
|
+
|
|
516
|
+
// Skip comments and empty lines
|
|
517
|
+
if (line.startsWith('//') || line.startsWith('*') || line.length === 0) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Skip lines that are clearly assertions
|
|
522
|
+
if (line.includes('expect(') || line.includes('assert') || line.includes('should')) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Skip setup actions (allowlist)
|
|
527
|
+
if (this.isSetupAction(line)) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Skip UI setup patterns (getting elements)
|
|
532
|
+
if (this.isUISetupAction(line)) {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Skip UI setup patterns (getting elements)
|
|
537
|
+
if (this.isUISetupAction(line)) {
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Look for service/method calls that are actual actions
|
|
542
|
+
for (const pattern of allPatterns) {
|
|
543
|
+
pattern.lastIndex = 0; // Reset regex
|
|
544
|
+
if (pattern.test(line) && !foundActions.has(i)) {
|
|
545
|
+
// Additional validation: must be an assignment or standalone call
|
|
546
|
+
if (line.includes('=') || line.includes('await') || line.endsWith(');')) {
|
|
547
|
+
foundActions.add(i);
|
|
548
|
+
count++;
|
|
549
|
+
break; // Only count once per line
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return count;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
countControlFlow(content) {
|
|
559
|
+
let count = 0;
|
|
560
|
+
const lines = content.split('\n');
|
|
561
|
+
|
|
562
|
+
for (let i = 0; i < lines.length; i++) {
|
|
563
|
+
const line = lines[i].trim();
|
|
564
|
+
|
|
565
|
+
// Skip comments, empty lines, and assertion lines
|
|
566
|
+
if (line.startsWith('//') || line.startsWith('*') || line.length === 0) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Skip assertion lines that contain expect, assert, should
|
|
571
|
+
if (line.includes('expect(') || line.includes('assert') || line.includes('should') ||
|
|
572
|
+
line.includes('.rejects.') || line.includes('.resolves.') || line.includes('toThrow')) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Skip UI interaction loops (legitimate testing pattern)
|
|
577
|
+
if (this.isUIInteractionLoop(line, lines, i)) {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Check for actual control flow statements
|
|
582
|
+
for (const pattern of this.compiledPatterns.controlFlow) {
|
|
583
|
+
pattern.lastIndex = 0; // Reset regex
|
|
584
|
+
if (pattern.test(line)) {
|
|
585
|
+
count++;
|
|
586
|
+
break; // Only count once per line
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return count;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
getLineNumber(content, index) {
|
|
595
|
+
return content.substring(0, index).split('\n').length;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
getColumnNumber(content, index) {
|
|
599
|
+
const beforeIndex = content.substring(0, index);
|
|
600
|
+
const lastNewlineIndex = beforeIndex.lastIndexOf('\n');
|
|
601
|
+
return index - lastNewlineIndex;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Extract the main subject/object being asserted from an assertion line
|
|
606
|
+
*/
|
|
607
|
+
extractAssertionSubject(line) {
|
|
608
|
+
// Remove expect( wrapper and get the core subject
|
|
609
|
+
const expectMatch = line.match(/expect\(([^)]+)\)/);
|
|
610
|
+
if (!expectMatch) return null;
|
|
611
|
+
|
|
612
|
+
const subject = expectMatch[1].trim();
|
|
613
|
+
|
|
614
|
+
// Handle method chains: include first method for more specific grouping
|
|
615
|
+
// e.g., "accountRepository.createQueryBuilder().where" → "accountRepository.createQueryBuilder"
|
|
616
|
+
const methodChainMatch = subject.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*\.[a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
617
|
+
if (methodChainMatch) {
|
|
618
|
+
return methodChainMatch[1];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Handle simple method calls: "accountRepository.createQueryBuilder"
|
|
622
|
+
const methodCallMatch = subject.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*\.[a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
623
|
+
if (methodCallMatch) {
|
|
624
|
+
return methodCallMatch[1];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Extract base object/variable name (before dots/brackets)
|
|
628
|
+
const baseMatch = subject.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
629
|
+
if (baseMatch) {
|
|
630
|
+
return baseMatch[1];
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return subject;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Group assertions by their context/subject to detect unrelated expectations
|
|
638
|
+
*/
|
|
639
|
+
groupAssertionsByContext(assertions) {
|
|
640
|
+
const contextGroups = {};
|
|
641
|
+
|
|
642
|
+
for (const assertion of assertions) {
|
|
643
|
+
if (!assertion.subject) continue;
|
|
644
|
+
|
|
645
|
+
// Group by base subject name
|
|
646
|
+
const baseSubject = assertion.subject;
|
|
647
|
+
|
|
648
|
+
if (!contextGroups[baseSubject]) {
|
|
649
|
+
contextGroups[baseSubject] = [];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
contextGroups[baseSubject].push(assertion);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Convert to array and filter out single assertion groups (they're fine)
|
|
656
|
+
const groups = Object.values(contextGroups);
|
|
657
|
+
|
|
658
|
+
// Merge related contexts (e.g., screen, mockSignIn, mockPush are often related in UI tests)
|
|
659
|
+
return this.mergeRelatedContexts(groups);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Merge context groups that are conceptually related
|
|
664
|
+
*/
|
|
665
|
+
mergeRelatedContexts(groups) {
|
|
666
|
+
const relatedPatterns = [
|
|
667
|
+
['screen', 'component', 'element'], // UI testing
|
|
668
|
+
['spy', 'stub'], // Mocking (removed 'mock' - too broad)
|
|
669
|
+
['response', 'data'], // Data validation (removed 'result' - too broad)
|
|
670
|
+
['state', 'props', 'context'] // State management
|
|
671
|
+
];
|
|
672
|
+
|
|
673
|
+
const mergedGroups = [];
|
|
674
|
+
const processedGroups = new Set();
|
|
675
|
+
|
|
676
|
+
for (let i = 0; i < groups.length; i++) {
|
|
677
|
+
if (processedGroups.has(i)) continue;
|
|
678
|
+
|
|
679
|
+
let currentGroup = [...groups[i]];
|
|
680
|
+
processedGroups.add(i);
|
|
681
|
+
|
|
682
|
+
// Find related groups to merge
|
|
683
|
+
for (let j = i + 1; j < groups.length; j++) {
|
|
684
|
+
if (processedGroups.has(j)) continue;
|
|
685
|
+
|
|
686
|
+
const group1Subjects = currentGroup.map(a => a.subject.toLowerCase());
|
|
687
|
+
const group2Subjects = groups[j].map(a => a.subject.toLowerCase());
|
|
688
|
+
|
|
689
|
+
// Check if groups are related based on patterns
|
|
690
|
+
const areRelated = relatedPatterns.some(pattern => {
|
|
691
|
+
const group1HasPattern = group1Subjects.some(s => pattern.some(p => s.includes(p)));
|
|
692
|
+
const group2HasPattern = group2Subjects.some(s => pattern.some(p => s.includes(p)));
|
|
693
|
+
return group1HasPattern && group2HasPattern;
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
if (areRelated) {
|
|
697
|
+
currentGroup = currentGroup.concat(groups[j]);
|
|
698
|
+
processedGroups.add(j);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
mergedGroups.push(currentGroup);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return mergedGroups;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Check if the test content represents a form interaction sequence
|
|
710
|
+
* that should be treated as a single behavior
|
|
711
|
+
*/
|
|
712
|
+
isFormInteractionSequence(content) {
|
|
713
|
+
if (!this.allowlist.formInteractionSequences) {
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
for (const pattern of this.allowlist.formInteractionSequences) {
|
|
718
|
+
const regex = new RegExp(pattern, 'gs'); // global, dotAll
|
|
719
|
+
if (regex.test(content)) {
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Check if a line represents a setup action that shouldn't count toward action limit
|
|
728
|
+
*/
|
|
729
|
+
isSetupAction(line) {
|
|
730
|
+
if (!this.allowlist.setupActionPatterns) {
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
for (const pattern of this.allowlist.setupActionPatterns) {
|
|
735
|
+
const regex = new RegExp(pattern, 'i');
|
|
736
|
+
if (regex.test(line)) {
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Check if content contains UI interaction workflow patterns
|
|
745
|
+
*/
|
|
746
|
+
isUIInteractionWorkflow(content) {
|
|
747
|
+
if (!this.config.options.allowlist?.uiInteractionWorkflows) {
|
|
748
|
+
if (this.verbose) {
|
|
749
|
+
console.log(`[DEBUG] 🧪 C065: No UI interaction workflows config found`);
|
|
750
|
+
}
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const patterns = this.config.options.allowlist.uiInteractionWorkflows;
|
|
755
|
+
if (this.verbose) {
|
|
756
|
+
console.log(`[DEBUG] 🧪 C065: Checking ${patterns.length} UI workflow patterns`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const isWorkflow = patterns.some(pattern => {
|
|
760
|
+
const regex = new RegExp(pattern, 'gs'); // global, dotAll
|
|
761
|
+
const matches = regex.test(content);
|
|
762
|
+
if (this.verbose && matches) {
|
|
763
|
+
console.log(`[DEBUG] 🧪 C065: UI workflow pattern matched: ${pattern}`);
|
|
764
|
+
}
|
|
765
|
+
return matches;
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
if (this.verbose) {
|
|
769
|
+
console.log(`[DEBUG] 🧪 C065: UI workflow detected: ${isWorkflow}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return isWorkflow;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Check if a line is a UI setup action (getting DOM elements)
|
|
777
|
+
*/
|
|
778
|
+
isUISetupAction(line) {
|
|
779
|
+
if (!this.config.options.allowlist?.uiSetupPatterns) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const patterns = this.config.options.allowlist.uiSetupPatterns;
|
|
784
|
+
return patterns.some(pattern => {
|
|
785
|
+
const regex = new RegExp(pattern, 'i');
|
|
786
|
+
return regex.test(line);
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Check if test method contains UI interactions (more lenient action counting)
|
|
792
|
+
*/
|
|
793
|
+
isUITestMethod(content) {
|
|
794
|
+
const uiPatterns = [
|
|
795
|
+
'fireEvent\\.',
|
|
796
|
+
'render\\(',
|
|
797
|
+
'getByRole\\(',
|
|
798
|
+
'queryByText\\(',
|
|
799
|
+
'getByText\\(',
|
|
800
|
+
'findByRole\\(',
|
|
801
|
+
'user\\.',
|
|
802
|
+
'screen\\.'
|
|
803
|
+
];
|
|
804
|
+
|
|
805
|
+
return uiPatterns.some(pattern => {
|
|
806
|
+
const regex = new RegExp(pattern, 'i');
|
|
807
|
+
return regex.test(content);
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Check if a control flow statement is a UI interaction loop (legitimate testing pattern)
|
|
813
|
+
*/
|
|
814
|
+
isUIInteractionLoop(line, lines, lineIndex) {
|
|
815
|
+
// Check if it's a for loop
|
|
816
|
+
if (!/\bfor\s*\(/.test(line)) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Look for UI element iteration patterns
|
|
821
|
+
const uiIterationPatterns = [
|
|
822
|
+
/for\s*\(.*\bcheckbox\b/, // for (const checkbox of listCheckbox)
|
|
823
|
+
/for\s*\(.*\bbutton\b/, // for (const button of buttons)
|
|
824
|
+
/for\s*\(.*\belement\b/, // for (const element of elements)
|
|
825
|
+
/for\s*\(.*\bitem\b/, // for (const item of items)
|
|
826
|
+
/for\s*\(.*\bnode\b/, // for (const node of nodes)
|
|
827
|
+
/for\s*\(.*getAll.*\)/, // for (const x of getAllByRole(...))
|
|
828
|
+
/for\s*\(.*queryAll.*\)/, // for (const x of queryAllByText(...))
|
|
829
|
+
];
|
|
830
|
+
|
|
831
|
+
// Check if loop variable matches UI element patterns
|
|
832
|
+
if (uiIterationPatterns.some(pattern => pattern.test(line))) {
|
|
833
|
+
// Check if loop body contains UI interactions (next few lines)
|
|
834
|
+
const nextLines = lines.slice(lineIndex + 1, lineIndex + 5);
|
|
835
|
+
const hasUIInteraction = nextLines.some(nextLine =>
|
|
836
|
+
/fireEvent\.|user\.|click\(|type\(|change\(/i.test(nextLine)
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
if (hasUIInteraction) {
|
|
840
|
+
if (this.verbose) {
|
|
841
|
+
console.log(`[DEBUG] 🧪 C065: UI interaction loop detected, skipping control flow violation`);
|
|
842
|
+
}
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
module.exports = C065OneBehaviorPerTestAnalyzer;
|