@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/config/defaults/default.json +2 -1
  3. package/config/rule-analysis-strategies.js +20 -0
  4. package/config/rules/enhanced-rules-registry.json +247 -53
  5. package/core/file-targeting-service.js +98 -7
  6. package/package.json +1 -1
  7. package/rules/common/C065_one_behavior_per_test/analyzer.js +851 -0
  8. package/rules/common/C065_one_behavior_per_test/config.json +95 -0
  9. package/rules/security/S020_no_eval_dynamic_code/README.md +136 -0
  10. package/rules/security/S020_no_eval_dynamic_code/analyzer.js +263 -0
  11. package/rules/security/S020_no_eval_dynamic_code/config.json +54 -0
  12. package/rules/security/S020_no_eval_dynamic_code/regex-based-analyzer.js +307 -0
  13. package/rules/security/S020_no_eval_dynamic_code/symbol-based-analyzer.js +280 -0
  14. package/rules/security/S024_xpath_xxe_protection/symbol-based-analyzer.js +3 -3
  15. package/rules/security/S025_server_side_validation/symbol-based-analyzer.js +3 -4
  16. package/rules/security/S030_directory_browsing_protection/README.md +128 -0
  17. package/rules/security/S030_directory_browsing_protection/analyzer.js +264 -0
  18. package/rules/security/S030_directory_browsing_protection/config.json +63 -0
  19. package/rules/security/S030_directory_browsing_protection/regex-based-analyzer.js +483 -0
  20. package/rules/security/S030_directory_browsing_protection/symbol-based-analyzer.js +539 -0
  21. package/rules/security/S033_samesite_session_cookies/symbol-based-analyzer.js +8 -9
  22. package/rules/security/S037_cache_headers/README.md +128 -0
  23. package/rules/security/S037_cache_headers/analyzer.js +263 -0
  24. package/rules/security/S037_cache_headers/config.json +50 -0
  25. package/rules/security/S037_cache_headers/regex-based-analyzer.js +463 -0
  26. package/rules/security/S037_cache_headers/symbol-based-analyzer.js +546 -0
  27. package/rules/security/S038_no_version_headers/README.md +234 -0
  28. package/rules/security/S038_no_version_headers/analyzer.js +262 -0
  29. package/rules/security/S038_no_version_headers/config.json +49 -0
  30. package/rules/security/S038_no_version_headers/regex-based-analyzer.js +339 -0
  31. package/rules/security/S038_no_version_headers/symbol-based-analyzer.js +375 -0
  32. package/rules/security/S039_no_session_tokens_in_url/README.md +198 -0
  33. package/rules/security/S039_no_session_tokens_in_url/analyzer.js +262 -0
  34. package/rules/security/S039_no_session_tokens_in_url/config.json +92 -0
  35. package/rules/security/S039_no_session_tokens_in_url/regex-based-analyzer.js +337 -0
  36. package/rules/security/S039_no_session_tokens_in_url/symbol-based-analyzer.js +443 -0
  37. package/rules/security/S049_short_validity_tokens/analyzer.js +175 -0
  38. package/rules/security/S049_short_validity_tokens/config.json +124 -0
  39. package/rules/security/S049_short_validity_tokens/regex-based-analyzer.js +295 -0
  40. package/rules/security/S049_short_validity_tokens/symbol-based-analyzer.js +389 -0
  41. package/rules/security/S051_password_length_policy/analyzer.js +410 -0
  42. package/rules/security/S051_password_length_policy/config.json +83 -0
  43. package/rules/security/S052_weak_otp_entropy/analyzer.js +403 -0
  44. package/rules/security/S052_weak_otp_entropy/config.json +57 -0
  45. package/rules/security/S054_no_default_accounts/README.md +129 -0
  46. package/rules/security/S054_no_default_accounts/analyzer.js +792 -0
  47. package/rules/security/S054_no_default_accounts/config.json +101 -0
  48. package/rules/security/S056_log_injection_protection/analyzer.js +242 -0
  49. package/rules/security/S056_log_injection_protection/config.json +148 -0
  50. package/rules/security/S056_log_injection_protection/regex-based-analyzer.js +120 -0
  51. 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;