@sun-asterisk/sunlint 1.3.5 → 1.3.6

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 CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 🔧 **v1.3.6 - C067 False Positive Reduction (September 8, 2025)**
6
+
7
+ **Release Date**: September 8, 2025
8
+ **Type**: Bug Fix & Improvement
9
+
10
+ ### 🐛 **Bug Fixes**
11
+ - **FIXED**: C067 "no hardcoded config" rule - Massive false positive reduction
12
+ - **replace-fe**: From 296 → 2 violations (-99.3%)
13
+ - **replace-be**: From 171 → 3 violations (-98.2%)
14
+ - **jmb-app-be**: From 121 → 5 violations (-95.9%)
15
+ - **mdx-cycle-hack**: From 8 → 6 violations (-25%)
16
+
17
+ ### 🔧 **Technical Improvements**
18
+ - **ENHANCED**: C067 analyzer logic improvements
19
+ - Skip dummy/test files and entity files completely
20
+ - Exclude field mapping objects and ORM configurations
21
+ - Skip database constraint names (primaryKeyConstraintName, etc.)
22
+ - Focus only on truly environment-dependent configurations
23
+ - Exclude business logic constants and UI field mappings
24
+ - **IMPROVED**: Rule precision - Only flag real environment config issues
25
+ - API endpoints, AWS service URLs, application keys
26
+ - Credential values and connection strings
27
+ - Environment-dependent timeouts and ports
28
+
29
+ ### 📊 **Performance**
30
+ - **OPTIMIZED**: Reduced analysis noise by 95%+ on large projects
31
+ - **ENHANCED**: Better developer experience with fewer false alarms
32
+
33
+ ---
34
+
5
35
  ## 🔧 **v1.3.5 - Preset System Refactor (September 8, 2025)**
6
36
 
7
37
  **Release Date**: September 8, 2025
@@ -61,6 +61,11 @@ module.exports = {
61
61
  methods: ['regex'],
62
62
  accuracy: { regex: 90 }
63
63
  },
64
+ 'C070': {
65
+ reason: 'Real-time dependencies detection via timer/sleep patterns',
66
+ methods: ['regex'],
67
+ accuracy: { regex: 95 }
68
+ },
64
69
  'S001': {
65
70
  reason: 'Security patterns are often string-based',
66
71
  methods: ['regex', 'ast'],
@@ -1357,6 +1357,29 @@
1357
1357
  "heuristic": ["rules/common/C067_no_hardcoded_config/analyzer.js"]
1358
1358
  }
1359
1359
  },
1360
+ "C070": {
1361
+ "name": "No Real Time Tests",
1362
+ "description": "Tests should not depend on real time delays or sleeps. Use fake timers, clock injection, or condition-based waits to improve test reliability and speed.",
1363
+ "category": "testing",
1364
+ "severity": "error",
1365
+ "languages": ["typescript", "javascript"],
1366
+ "analyzer": "../rules/common/C070_no_real_time_tests/regex-analyzer.js",
1367
+ "config": "../rules/common/C070_no_real_time_tests/config.json",
1368
+ "version": "1.0.0",
1369
+ "status": "stable",
1370
+ "tags": ["testing", "flaky-tests", "timing", "fake-timers", "reliability"],
1371
+ "strategy": {
1372
+ "preferred": "ast",
1373
+ "fallbacks": ["regex"],
1374
+ "accuracy": {
1375
+ "ast": 95,
1376
+ "regex": 88
1377
+ }
1378
+ },
1379
+ "engineMappings": {
1380
+ "heuristic": ["../rules/common/C070_no_real_time_tests/regex-analyzer.js"]
1381
+ }
1382
+ },
1360
1383
  "C072": {
1361
1384
  "id": "C072",
1362
1385
  "name": "Single Test Behavior",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sunlint",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards",
5
5
  "main": "cli.js",
6
6
  "bin": {
@@ -246,7 +246,7 @@ class C067SymbolBasedAnalyzer {
246
246
  }
247
247
 
248
248
  isConfigOrTestFile(filePath) {
249
- // Skip config files themselves and test files (NOT dummy files used in production)
249
+ // Skip config files themselves and test files, including dummy/test data files
250
250
  const fileName = filePath.toLowerCase();
251
251
  const configPatterns = [
252
252
  /config\.(ts|js|json)$/,
@@ -264,9 +264,12 @@ class C067SymbolBasedAnalyzer {
264
264
  /\/test\//,
265
265
  /\/tests\//,
266
266
  /\.stories\.(ts|tsx|js|jsx)$/,
267
- /\.mock\.(ts|tsx|js|jsx)$/
268
- // NOTE: Deliberately NOT including /dummy/ because dummy files
269
- // in production code often contain hardcoded config that should be flagged
267
+ /\.mock\.(ts|tsx|js|jsx)$/,
268
+ /\/dummy\//, // Skip dummy data files
269
+ /dummy\.(ts|js)$/, // Skip dummy files
270
+ /test-fixtures\//, // Skip test fixture files
271
+ /\.fixture\.(ts|js)$/, // Skip fixture files
272
+ /entity\.(ts|js)$/ // Skip entity/ORM files (contain DB constraints)
270
273
  ];
271
274
 
272
275
  return configPatterns.some(pattern => pattern.test(fileName)) ||
@@ -451,66 +454,97 @@ class C067SymbolBasedAnalyzer {
451
454
  const propertyName = nameNode.getText();
452
455
  const position = sourceFile.getLineAndColumnAtPos(node.getStart());
453
456
 
454
- // Skip field mapping objects
457
+ // Skip ALL field mapping objects and ORM/database entity configurations
455
458
  const ancestorObj = node.getParent();
456
459
  if (ancestorObj && Node.isObjectLiteralExpression(ancestorObj)) {
457
460
  const objParent = ancestorObj.getParent();
458
461
  if (objParent && Node.isVariableDeclaration(objParent)) {
459
462
  const varName = objParent.getName();
460
- if (/mapping|map|field|column|decode/i.test(varName)) {
461
- return null; // Skip field mapping objects
463
+ // Skip field mappings, database schemas, etc.
464
+ if (/mapping|map|field|column|decode|schema|entity|constraint|table/i.test(varName)) {
465
+ return null;
462
466
  }
463
467
  }
468
+
469
+ // Check if this looks like a table column definition or field mapping
470
+ const objText = ancestorObj.getText();
471
+ if (/primaryKeyConstraintName|foreignKeyConstraintName|key.*may contain/i.test(objText)) {
472
+ return null; // Skip database constraint definitions
473
+ }
464
474
  }
465
475
 
466
- // Skip common business logic properties that aren't environment-dependent
476
+ // Skip properties that are clearly field mappings or business data
467
477
  const businessLogicProperties = [
468
- 'endpoint', 'path', 'route', // API routing
469
- 'limit', 'pageSize', 'batchSize', // Pagination (usually business logic)
470
- 'retry', 'retries', 'maxRetries', // Retry logic (usually business logic)
471
- 'count', 'max', 'min' // Common limits
478
+ // Field mappings
479
+ 'key', 'field', 'dataKey', 'valueKey', 'labelKey', 'sortKey',
480
+ // Business logic
481
+ 'endpoint', 'path', 'route', 'method',
482
+ 'limit', 'pageSize', 'batchSize', 'maxResults',
483
+ 'retry', 'retries', 'maxRetries', 'attempts',
484
+ 'count', 'max', 'min', 'size', 'length',
485
+ // UI properties
486
+ 'className', 'style', 'disabled', 'readonly',
487
+ // Database/ORM
488
+ 'primaryKeyConstraintName', 'foreignKeyConstraintName', 'constraintName',
489
+ 'tableName', 'columnName', 'schemaName'
472
490
  ];
473
491
 
474
492
  const lowerPropertyName = propertyName.toLowerCase();
475
493
  if (businessLogicProperties.some(prop => lowerPropertyName.includes(prop))) {
476
- // Only flag if it's clearly environment-dependent
477
- let value = null;
478
- if (valueNode.getKind() === SyntaxKind.StringLiteral) {
479
- value = valueNode.getLiteralValue();
480
- // Only flag URLs or clearly environment-dependent strings
481
- if (!this.configPatterns.urls.regex.test(value) || !this.isEnvironmentDependentUrl(value)) {
482
- return null;
483
- }
484
- } else if (valueNode.getKind() === SyntaxKind.NumericLiteral) {
485
- value = valueNode.getLiteralValue();
486
- const parentContext = this.getParentContext(node);
487
- // Only flag if it's clearly environment-dependent (like ports, large timeouts)
488
- if (!this.configPatterns.environmentNumbers.isEnvironmentDependent(value, parentContext)) {
489
- return null;
490
- }
491
- }
494
+ return null; // Skip these completely
492
495
  }
493
496
 
494
- // Check if property name suggests environment-dependent configuration
495
- if (this.isEnvironmentDependentProperty(propertyName)) {
496
- let value = null;
497
+ // Only check for CLEARLY environment-dependent properties
498
+ const trulyEnvironmentDependentProps = [
499
+ 'baseurl', 'baseURL', 'host', 'hostname', 'server', 'endpoint',
500
+ 'apikey', 'api_key', 'secret_key', 'client_secret',
501
+ 'database', 'connectionstring', 'dbhost', 'dbport',
502
+ 'port', 'timeout', // Only when they have suspicious values
503
+ 'bucket', 'region', // Cloud-specific
504
+ 'clientid', 'tenantid' // OAuth-specific
505
+ ];
506
+
507
+ if (!trulyEnvironmentDependentProps.some(prop => lowerPropertyName.includes(prop))) {
508
+ return null; // Not clearly environment-dependent
509
+ }
510
+
511
+ let value = null;
512
+ let configType = null;
513
+
514
+ if (valueNode.getKind() === SyntaxKind.StringLiteral) {
515
+ value = valueNode.getLiteralValue();
497
516
 
498
- if (valueNode.getKind() === SyntaxKind.StringLiteral) {
499
- value = valueNode.getLiteralValue();
500
- } else if (valueNode.getKind() === SyntaxKind.NumericLiteral) {
501
- value = valueNode.getLiteralValue();
517
+ // Only flag URLs or clearly sensitive values
518
+ if (this.configPatterns.urls.regex.test(value) && this.isEnvironmentDependentUrl(value)) {
519
+ configType = 'url';
520
+ } else if (this.isRealCredential(value, propertyName)) {
521
+ configType = 'credential';
522
+ } else {
523
+ return null; // Skip other string values
502
524
  }
525
+ } else if (valueNode.getKind() === SyntaxKind.NumericLiteral) {
526
+ value = valueNode.getLiteralValue();
527
+ const parentContext = this.getParentContext(node);
503
528
 
504
- if (value !== null && this.looksLikeEnvironmentConfig(propertyName, value)) {
505
- return {
506
- type: 'property_config',
507
- value: value,
508
- line: position.line,
509
- column: position.column,
510
- node: node,
511
- propertyName: propertyName
512
- };
529
+ // Only flag numbers that are clearly environment-dependent
530
+ if (this.configPatterns.environmentNumbers.isEnvironmentDependent(value, parentContext)) {
531
+ configType = 'environment_config';
532
+ } else {
533
+ return null;
513
534
  }
535
+ } else {
536
+ return null; // Skip other value types
537
+ }
538
+
539
+ if (configType) {
540
+ return {
541
+ type: configType,
542
+ value: value,
543
+ line: position.line,
544
+ column: position.column,
545
+ node: node,
546
+ propertyName: propertyName
547
+ };
514
548
  }
515
549
 
516
550
  return null;
@@ -0,0 +1,320 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * C070 - Tests Should Not Depend on Real Time
6
+ * Detects real-time sleeps/timeouts in test files and suggests fake timers
7
+ *
8
+ * Focus: Improve test reliability by avoiding time-dependent flaky tests
9
+ */
10
+ class C070TestRealTimeAnalyzer {
11
+ constructor() {
12
+ this.ruleId = 'C070';
13
+ this.configPath = path.join(__dirname, 'config.json');
14
+ this.config = this.loadConfig();
15
+ }
16
+
17
+ loadConfig() {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(this.configPath, 'utf8'));
20
+ } catch (error) {
21
+ console.warn(`Failed to load config for ${this.ruleId}:`, error.message);
22
+ return this.getDefaultConfig();
23
+ }
24
+ }
25
+
26
+ getDefaultConfig() {
27
+ return {
28
+ options: {
29
+ timerApis: {
30
+ ts_js: [
31
+ "setTimeout\\s*\\(",
32
+ "setInterval\\s*\\(",
33
+ "\\.sleep\\s*\\(",
34
+ "\\.delay\\s*\\(",
35
+ "\\.wait\\s*\\(",
36
+ "new\\s+Promise.*setTimeout"
37
+ ]
38
+ },
39
+ fakeTimerDetectors: {
40
+ jest_vitest: [
41
+ "jest\\.useFakeTimers\\(\\)",
42
+ "vi\\.useFakeTimers\\(\\)",
43
+ "jest\\.advanceTimersByTime",
44
+ "vi\\.advanceTimersByTime"
45
+ ]
46
+ },
47
+ busyPollingDetectors: {
48
+ ts_js: ["Date\\.now\\(\\)", "new\\s+Date\\(\\)"]
49
+ },
50
+ allowAnnotations: ["@perf", "@benchmark", "@allow-real-time", "// @allow-real-time"]
51
+ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Check if file is a test file
57
+ */
58
+ isTestFile(filePath) {
59
+ const testPatterns = [
60
+ /\.test\.(js|ts|jsx|tsx)$/,
61
+ /\.spec\.(js|ts|jsx|tsx)$/,
62
+ /__tests__\//,
63
+ /\/tests?\//,
64
+ /test-cases\.(js|ts)$/ // Add pattern for our test cases
65
+ ];
66
+ return testPatterns.some(pattern => pattern.test(filePath));
67
+ }
68
+
69
+ /**
70
+ * Check if line has annotation allowing real-time
71
+ */
72
+ hasAllowAnnotation(content, lineIndex) {
73
+ const allowAnnotations = this.config.options.allowAnnotations || [];
74
+ const lines = content.split('\n');
75
+
76
+ // Check current line and 2 lines above for annotations
77
+ for (let i = Math.max(0, lineIndex - 2); i <= lineIndex; i++) {
78
+ const line = lines[i] || '';
79
+ for (const annotation of allowAnnotations) {
80
+ if (line.includes(annotation)) {
81
+ return true;
82
+ }
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /**
89
+ * Check if fake timers are used in the file
90
+ */
91
+ hasFakeTimers(content) {
92
+ const fakeTimerPatterns = this.config.options.fakeTimerDetectors.jest_vitest || [];
93
+ return fakeTimerPatterns.some(pattern => {
94
+ const regex = new RegExp(pattern, 'g');
95
+ return regex.test(content);
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Detect timer API violations
101
+ */
102
+ detectTimerViolations(content, filePath) {
103
+ const violations = [];
104
+ const lines = content.split('\n');
105
+ const timerPatterns = this.config.options.timerApis.ts_js || this.getDefaultConfig().options.timerApis.ts_js;
106
+ const hasFakeTimersInFile = this.hasFakeTimers(content);
107
+
108
+ timerPatterns.forEach(pattern => {
109
+ // Convert config patterns (* wildcards) to proper regex
110
+ let regexPattern = pattern;
111
+ if (pattern.includes('(*)')) {
112
+ regexPattern = pattern.replace(/\(\*\)/g, '\\([^)]*\\)');
113
+ }
114
+ if (pattern.includes('*')) {
115
+ regexPattern = pattern.replace(/\*/g, '[^)]*');
116
+ }
117
+
118
+ const regex = new RegExp(regexPattern, 'g');
119
+
120
+ lines.forEach((line, index) => {
121
+ const trimmedLine = line.trim();
122
+
123
+ // Skip comments and empty lines
124
+ if (trimmedLine.startsWith('//') || trimmedLine.startsWith('*') || !trimmedLine) {
125
+ return;
126
+ }
127
+
128
+ // Skip if has allow annotation
129
+ if (this.hasAllowAnnotation(content, index)) {
130
+ return;
131
+ }
132
+
133
+ const matches = [...line.matchAll(regex)];
134
+ if (matches.length > 0) {
135
+ matches.forEach(match => {
136
+ const column = match.index + 1;
137
+
138
+ let suggestion = "Use fake timers instead of real-time delays in tests.";
139
+ let severity = "error";
140
+
141
+ // Specific suggestions based on pattern
142
+ if (pattern.includes('setTimeout') || pattern.includes('setInterval')) {
143
+ if (!hasFakeTimersInFile) {
144
+ suggestion = "Use jest.useFakeTimers() and jest.advanceTimersByTime() instead of setTimeout/setInterval.";
145
+ } else {
146
+ suggestion = "You have fake timers setup. Use jest.advanceTimersByTime() to control time instead of real setTimeout.";
147
+ }
148
+ } else if (pattern.includes('sleep') || pattern.includes('delay')) {
149
+ suggestion = "Replace sleep/delay with fake timers or condition-based waiting.";
150
+ } else if (pattern.includes('Promise.*setTimeout')) {
151
+ suggestion = "Replace Promise+setTimeout with fake timers: await jest.advanceTimersByTimeAsync().";
152
+ }
153
+
154
+ violations.push({
155
+ line: index + 1,
156
+ column: column,
157
+ message: `Avoid real-time ${match[0]} in tests. ${suggestion}`,
158
+ severity: severity,
159
+ ruleId: this.ruleId,
160
+ evidence: line.trim(),
161
+ suggestion: suggestion
162
+ });
163
+ });
164
+ }
165
+ });
166
+ });
167
+
168
+ return violations;
169
+ }
170
+
171
+ /**
172
+ * Detect busy polling violations (Date.now(), new Date() in loops)
173
+ */
174
+ detectBusyPollingViolations(content, filePath) {
175
+ const violations = [];
176
+ const lines = content.split('\n');
177
+ const pollingPatterns = this.config.options.busyPollingDetectors.ts_js || [];
178
+
179
+ pollingPatterns.forEach(pattern => {
180
+ const regex = new RegExp(pattern, 'g');
181
+
182
+ lines.forEach((line, index) => {
183
+ const trimmedLine = line.trim();
184
+
185
+ // Skip comments
186
+ if (trimmedLine.startsWith('//') || trimmedLine.startsWith('*') || !trimmedLine) {
187
+ return;
188
+ }
189
+
190
+ // Skip if has allow annotation
191
+ if (this.hasAllowAnnotation(content, index)) {
192
+ return;
193
+ }
194
+
195
+ // Look for Date.now()/new Date() in potential polling contexts
196
+ const matches = line.match(regex);
197
+ if (matches && this.isLikelyPolling(lines, index)) {
198
+ matches.forEach(match => {
199
+ const column = line.indexOf(match) + 1;
200
+
201
+ violations.push({
202
+ line: index + 1,
203
+ column: column,
204
+ message: `Avoid busy polling with ${match} in tests. Use condition-based waiting instead.`,
205
+ severity: "warning",
206
+ ruleId: this.ruleId,
207
+ evidence: line.trim(),
208
+ suggestion: "Use waitFor conditions or fake timers instead of polling Date.now()."
209
+ });
210
+ });
211
+ }
212
+ });
213
+ });
214
+
215
+ return violations;
216
+ }
217
+
218
+ /**
219
+ * Check if Date.now()/new Date() usage looks like polling
220
+ */
221
+ isLikelyPolling(lines, currentIndex) {
222
+ // Look for while/for loops, or repeated checks around this line
223
+ const contextRange = 5;
224
+ const start = Math.max(0, currentIndex - contextRange);
225
+ const end = Math.min(lines.length - 1, currentIndex + contextRange);
226
+
227
+ for (let i = start; i <= end; i++) {
228
+ const line = lines[i].trim();
229
+ if (line.includes('while') || line.includes('for') ||
230
+ line.includes('setInterval') || line.includes('setTimeout')) {
231
+ return true;
232
+ }
233
+ }
234
+
235
+ return false;
236
+ }
237
+
238
+ /**
239
+ * Detect E2E specific violations
240
+ */
241
+ detectE2EViolations(content, filePath) {
242
+ const violations = [];
243
+ const lines = content.split('\n');
244
+ const e2ePatterns = this.config.options.timerApis.e2e || [];
245
+
246
+ e2ePatterns.forEach(pattern => {
247
+ const regex = new RegExp(pattern, 'g');
248
+
249
+ lines.forEach((line, index) => {
250
+ const trimmedLine = line.trim();
251
+
252
+ if (trimmedLine.startsWith('//') || !trimmedLine) {
253
+ return;
254
+ }
255
+
256
+ if (this.hasAllowAnnotation(content, index)) {
257
+ return;
258
+ }
259
+
260
+ const matches = line.match(regex);
261
+ if (matches) {
262
+ matches.forEach(match => {
263
+ const column = line.indexOf(match) + 1;
264
+
265
+ let suggestion = "Use element-based waiting instead of fixed timeouts.";
266
+ if (match.includes('page.waitForTimeout')) {
267
+ suggestion = "Use page.waitForSelector() or page.waitForFunction() instead of waitForTimeout().";
268
+ } else if (match.includes('cy.wait')) {
269
+ suggestion = "Use cy.get().should() or cy.intercept() instead of cy.wait() with fixed time.";
270
+ }
271
+
272
+ violations.push({
273
+ line: index + 1,
274
+ column: column,
275
+ message: `Avoid fixed timeout ${match} in E2E tests. ${suggestion}`,
276
+ severity: "warning",
277
+ ruleId: this.ruleId,
278
+ evidence: line.trim(),
279
+ suggestion: suggestion
280
+ });
281
+ });
282
+ }
283
+ });
284
+ });
285
+
286
+ return violations;
287
+ }
288
+
289
+ /**
290
+ * Main analysis function
291
+ */
292
+ analyze(content, filePath) {
293
+ // Only analyze test files
294
+ if (!this.isTestFile(filePath)) {
295
+ return [];
296
+ }
297
+
298
+ let violations = [];
299
+
300
+ try {
301
+ // Detect timer API violations
302
+ violations = violations.concat(this.detectTimerViolations(content, filePath));
303
+
304
+ // Detect busy polling violations
305
+ violations = violations.concat(this.detectBusyPollingViolations(content, filePath));
306
+
307
+ // Detect E2E violations (if file looks like E2E test)
308
+ if (filePath.includes('e2e') || content.includes('playwright') || content.includes('cypress')) {
309
+ violations = violations.concat(this.detectE2EViolations(content, filePath));
310
+ }
311
+
312
+ } catch (error) {
313
+ console.warn(`C070 analysis error for ${filePath}:`, error.message);
314
+ }
315
+
316
+ return violations;
317
+ }
318
+ }
319
+
320
+ module.exports = C070TestRealTimeAnalyzer;
@@ -0,0 +1,78 @@
1
+ {
2
+ "id": "C070",
3
+ "name": "C070_tests_should_not_depend_on_real_time",
4
+ "category": "testing",
5
+ "description": "C070 - Avoid real-time sleeps/timeouts in tests. Use fake timers, clock injection, or condition/event-based waits.",
6
+ "severity": "error",
7
+ "enabled": true,
8
+ "semantic": {
9
+ "enabled": true,
10
+ "priority": "high",
11
+ "fallback": "heuristic"
12
+ },
13
+ "patterns": {
14
+ "include": ["**/*.test.*", "**/*.spec.*", "**/__tests__/**"],
15
+ "exclude": ["**/performance/**", "**/benchmarks/**", "**/e2e/**"]
16
+ },
17
+ "options": {
18
+ "timerApis": {
19
+ "ts_js": [
20
+ "setTimeout\\s*\\(",
21
+ "setInterval\\s*\\(",
22
+ "\\.sleep\\s*\\(",
23
+ "\\.delay\\s*\\(",
24
+ "\\.wait\\s*\\(",
25
+ "new\\s+Promise.*setTimeout"
26
+ ],
27
+ "java": [
28
+ "Thread.sleep(*)",
29
+ "TimeUnit.*.sleep(*)"
30
+ ],
31
+ "go": [
32
+ "time.Sleep(*)"
33
+ ],
34
+ "e2e": [
35
+ "page\\.waitForTimeout\\s*\\(",
36
+ "cy\\.wait\\s*\\(\\s*[0-9]+"
37
+ ]
38
+ },
39
+ "fakeTimerDetectors": {
40
+ "jest_vitest": [
41
+ "jest\\.useFakeTimers\\(\\)", "vi\\.useFakeTimers\\(\\)",
42
+ "jest\\.advanceTimersByTime\\s*\\(", "vi\\.advanceTimersByTime\\s*\\(",
43
+ "jest\\.runAllTimers\\(\\)", "vi\\.runAllTimers\\(\\)"
44
+ ],
45
+ "rxjs": ["new TestScheduler(*)"]
46
+ },
47
+ "busyPollingDetectors": {
48
+ "ts_js": ["Date.now()", "new Date()"],
49
+ "java": ["System.currentTimeMillis()", "Instant.now()"],
50
+ "go": ["time.Now()"]
51
+ },
52
+ "policy": {
53
+ "forbidRealSleeps": true,
54
+ "requireFakeTimersWhenUsingTimers": true,
55
+ "preferConditionBasedWaits": true,
56
+ "allowE2EExplicitWaits": false,
57
+ "allowAnnotatedPerfTests": true
58
+ },
59
+ "allowAnnotations": [
60
+ "@perf", "@benchmark", "@allow-real-time", "// @allow-real-time"
61
+ ],
62
+ "suggestions": {
63
+ "ts_js": [
64
+ "Use jest/vi fake timers and advanceTimersByTime instead of sleep/timeout.",
65
+ "Await condition or event (locator.waitFor / cy.get(...).should(...)).",
66
+ "Inject a Clock or pass now(): number into the unit under test."
67
+ ],
68
+ "java": [
69
+ "Inject java.time.Clock; use Clock.fixed/Clock.offset in tests.",
70
+ "Use Awaitility untilAsserted instead of Thread.sleep."
71
+ ],
72
+ "go": [
73
+ "Use benbjohnson/clock mock; advance time in tests.",
74
+ "Avoid time.Sleep; wait on channels/conditions/events instead."
75
+ ]
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,424 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { CommentDetector } = require('../../utils/rule-helpers');
4
+
5
+ /**
6
+ * C070 - Tests Should Not Depend on Real Time
7
+ * Detects real-time sleeps/timeouts in test files and suggests fake timers
8
+ *
9
+ * Focus: Improve test reliability by avoiding time-dependent flaky tests
10
+ */
11
+ class C070TestRealTimeAnalyzer {
12
+ constructor() {
13
+ this.ruleId = 'C070';
14
+ this.configPath = path.join(__dirname, 'config.json');
15
+ this.config = this.loadConfig();
16
+ }
17
+
18
+ loadConfig() {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(this.configPath, 'utf8'));
21
+ } catch (error) {
22
+ console.warn(`Failed to load config for ${this.ruleId}:`, error.message);
23
+ return this.getDefaultConfig();
24
+ }
25
+ }
26
+
27
+ getDefaultConfig() {
28
+ return {
29
+ options: {
30
+ timerApis: {
31
+ ts_js: [
32
+ "setTimeout\\s*\\(",
33
+ "setInterval\\s*\\(",
34
+ "\\.sleep\\s*\\(",
35
+ "\\.delay\\s*\\(",
36
+ "\\.wait\\s*\\(",
37
+ "new\\s+Promise.*setTimeout"
38
+ ]
39
+ },
40
+ fakeTimerDetectors: {
41
+ jest_vitest: [
42
+ "jest\\.useFakeTimers\\(\\)",
43
+ "vi\\.useFakeTimers\\(\\)",
44
+ "jest\\.advanceTimersByTime",
45
+ "vi\\.advanceTimersByTime"
46
+ ]
47
+ },
48
+ busyPollingDetectors: {
49
+ ts_js: ["Date\\.now\\(\\)", "new\\s+Date\\(\\)"]
50
+ },
51
+ allowAnnotations: ["@perf", "@benchmark", "@allow-real-time", "// @allow-real-time"]
52
+ }
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Check if file is a test file
58
+ */
59
+ isTestFile(filePath) {
60
+ const testPatterns = [
61
+ /\.test\.(js|ts|jsx|tsx)$/,
62
+ /\.spec\.(js|ts|jsx|tsx)$/,
63
+ /__tests__\//,
64
+ /\/tests?\//,
65
+ /test-cases\.(js|ts)$/ // Add pattern for our test cases
66
+ ];
67
+ return testPatterns.some(pattern => pattern.test(filePath));
68
+ }
69
+
70
+ /**
71
+ * Check if line has annotation allowing real-time
72
+ */
73
+ hasAllowAnnotation(content, lineIndex) {
74
+ const allowAnnotations = this.config.options.allowAnnotations || [];
75
+ const lines = content.split('\n');
76
+
77
+ // Check current line and 2 lines above for annotations
78
+ for (let i = Math.max(0, lineIndex - 2); i <= lineIndex; i++) {
79
+ const line = lines[i] || '';
80
+ for (const annotation of allowAnnotations) {
81
+ if (line.includes(annotation)) {
82
+ return true;
83
+ }
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * Check if fake timers are used in the file
91
+ */
92
+ hasFakeTimers(content) {
93
+ const fakeTimerPatterns = this.config.options.fakeTimerDetectors.jest_vitest || [];
94
+ return fakeTimerPatterns.some(pattern => {
95
+ const regex = new RegExp(pattern, 'g');
96
+ return regex.test(content);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Detect timer API violations
102
+ */
103
+ detectTimerViolations(content, filePath) {
104
+ const violations = [];
105
+ const lines = content.split('\n');
106
+ const timerPatterns = this.config.options.timerApis.ts_js || this.getDefaultConfig().options.timerApis.ts_js;
107
+ const hasFakeTimersInFile = this.hasFakeTimers(content);
108
+
109
+ timerPatterns.forEach(pattern => {
110
+ // Convert config patterns (* wildcards) to proper regex
111
+ let regexPattern = pattern;
112
+ if (pattern.includes('(*)')) {
113
+ regexPattern = pattern.replace(/\(\*\)/g, '\\([^)]*\\)');
114
+ }
115
+ if (pattern.includes('*')) {
116
+ regexPattern = pattern.replace(/\*/g, '[^)]*');
117
+ }
118
+
119
+ const regex = new RegExp(regexPattern, 'g');
120
+
121
+ lines.forEach((line, index) => {
122
+ const trimmedLine = line.trim();
123
+
124
+ // Skip empty lines
125
+ if (!trimmedLine) {
126
+ return;
127
+ }
128
+
129
+ // Skip if entire line is in block comment
130
+ if (CommentDetector.isLineInBlockComment(lines, index)) {
131
+ return;
132
+ }
133
+
134
+ // Skip if has allow annotation
135
+ if (this.hasAllowAnnotation(content, index)) {
136
+ return;
137
+ }
138
+
139
+ const matches = [...line.matchAll(regex)];
140
+ if (matches.length > 0) {
141
+ matches.forEach(match => {
142
+ const column = match.index + 1;
143
+
144
+ // Skip if match position is inside a comment
145
+ if (CommentDetector.isPositionInComment(line, match.index)) {
146
+ return;
147
+ }
148
+
149
+ let suggestion = "Use fake timers instead of real-time delays in tests.";
150
+ let severity = "error";
151
+
152
+ // Specific suggestions based on pattern
153
+ if (pattern.includes('setTimeout') || pattern.includes('setInterval')) {
154
+ if (!hasFakeTimersInFile) {
155
+ suggestion = "Use jest.useFakeTimers() and jest.advanceTimersByTime() instead of setTimeout/setInterval.";
156
+ } else {
157
+ suggestion = "You have fake timers setup. Use jest.advanceTimersByTime() to control time instead of real setTimeout.";
158
+ }
159
+ } else if (pattern.includes('sleep') || pattern.includes('delay')) {
160
+ suggestion = "Replace sleep/delay with fake timers or condition-based waiting.";
161
+ } else if (pattern.includes('Promise.*setTimeout')) {
162
+ suggestion = "Replace Promise+setTimeout with fake timers: await jest.advanceTimersByTimeAsync().";
163
+ }
164
+
165
+ violations.push({
166
+ file: filePath,
167
+ line: index + 1,
168
+ column: column,
169
+ message: `Avoid real-time ${match[0]} in tests. ${suggestion}`,
170
+ severity: severity,
171
+ ruleId: this.ruleId,
172
+ evidence: line.trim(),
173
+ suggestion: suggestion
174
+ });
175
+ });
176
+ }
177
+ });
178
+ });
179
+
180
+ return violations;
181
+ }
182
+
183
+ /**
184
+ * Detect busy polling violations (Date.now(), new Date() in loops)
185
+ */
186
+ detectBusyPollingViolations(content, filePath) {
187
+ const violations = [];
188
+ const lines = content.split('\n');
189
+ const pollingPatterns = this.config.options.busyPollingDetectors.ts_js || [];
190
+
191
+ pollingPatterns.forEach(pattern => {
192
+ const regex = new RegExp(pattern, 'g');
193
+
194
+ lines.forEach((line, index) => {
195
+ const trimmedLine = line.trim();
196
+
197
+ // Skip empty lines
198
+ if (!trimmedLine) {
199
+ return;
200
+ }
201
+
202
+ // Skip if entire line is in block comment
203
+ if (CommentDetector.isLineInBlockComment(lines, index)) {
204
+ return;
205
+ }
206
+
207
+ // Skip if has allow annotation
208
+ if (this.hasAllowAnnotation(content, index)) {
209
+ return;
210
+ }
211
+
212
+ // Look for Date.now()/new Date() in potential polling contexts
213
+ const matches = line.match(regex);
214
+ if (matches && this.isLikelyPolling(lines, index)) {
215
+ matches.forEach(match => {
216
+ const column = line.indexOf(match) + 1;
217
+
218
+ // Skip if match position is inside a comment
219
+ if (CommentDetector.isPositionInComment(line, line.indexOf(match))) {
220
+ return;
221
+ }
222
+
223
+ violations.push({
224
+ file: filePath,
225
+ line: index + 1,
226
+ column: column,
227
+ message: `Avoid busy polling with ${match} in tests. Use condition-based waiting instead.`,
228
+ severity: "warning",
229
+ ruleId: this.ruleId,
230
+ evidence: line.trim(),
231
+ suggestion: "Use waitFor conditions or fake timers instead of polling Date.now()."
232
+ });
233
+ });
234
+ }
235
+ });
236
+ });
237
+
238
+ return violations;
239
+ }
240
+
241
+ /**
242
+ * Check if Date.now()/new Date() usage looks like polling
243
+ */
244
+ isLikelyPolling(lines, currentIndex) {
245
+ const currentLine = lines[currentIndex];
246
+
247
+ // Skip if new Date() has static parameters (test data)
248
+ if (currentLine.includes('new Date(') && /new Date\(\s*\d/.test(currentLine)) {
249
+ return false;
250
+ }
251
+
252
+ // Look for polling patterns around this line
253
+ const contextRange = 5;
254
+ const start = Math.max(0, currentIndex - contextRange);
255
+ const end = Math.min(lines.length - 1, currentIndex + contextRange);
256
+
257
+ let hasLoop = false;
258
+ let hasTimeCheck = false;
259
+
260
+ for (let i = start; i <= end; i++) {
261
+ const line = lines[i].trim().toLowerCase();
262
+
263
+ // Check for loop patterns
264
+ if (line.includes('while') && (line.includes('date.now') || line.includes('new date'))) {
265
+ hasLoop = true;
266
+ }
267
+
268
+ // Check for time-based conditions
269
+ if ((line.includes('date.now') || line.includes('new date')) &&
270
+ (line.includes(' - ') || line.includes(' < ') || line.includes(' > ')) &&
271
+ (line.includes('start') || line.includes('time') || line.includes('duration'))) {
272
+ hasTimeCheck = true;
273
+ }
274
+
275
+ // Check for explicit polling patterns
276
+ if (line.includes('setinterval') && (line.includes('date.now') || line.includes('new date'))) {
277
+ return true;
278
+ }
279
+ }
280
+
281
+ // Only flag as polling if both loop and time check are present
282
+ return hasLoop && hasTimeCheck;
283
+ }
284
+
285
+ /**
286
+ * Detect E2E specific violations
287
+ */
288
+ detectE2EViolations(content, filePath) {
289
+ const violations = [];
290
+ const lines = content.split('\n');
291
+ const e2ePatterns = this.config.options.timerApis.e2e || [];
292
+
293
+ e2ePatterns.forEach(pattern => {
294
+ const regex = new RegExp(pattern, 'g');
295
+
296
+ lines.forEach((line, index) => {
297
+ const trimmedLine = line.trim();
298
+
299
+ if (!trimmedLine) {
300
+ return;
301
+ }
302
+
303
+ // Skip if entire line is in block comment
304
+ if (CommentDetector.isLineInBlockComment(lines, index)) {
305
+ return;
306
+ }
307
+
308
+ if (this.hasAllowAnnotation(content, index)) {
309
+ return;
310
+ }
311
+
312
+ const matches = line.match(regex);
313
+ if (matches) {
314
+ matches.forEach(match => {
315
+ const column = line.indexOf(match) + 1;
316
+
317
+ // Skip if match position is inside a comment
318
+ if (CommentDetector.isPositionInComment(line, line.indexOf(match))) {
319
+ return;
320
+ }
321
+
322
+ let suggestion = "Use element-based waiting instead of fixed timeouts.";
323
+ if (match.includes('page.waitForTimeout')) {
324
+ suggestion = "Use page.waitForSelector() or page.waitForFunction() instead of waitForTimeout().";
325
+ } else if (match.includes('cy.wait')) {
326
+ suggestion = "Use cy.get().should() or cy.intercept() instead of cy.wait() with fixed time.";
327
+ }
328
+
329
+ violations.push({
330
+ file: filePath,
331
+ line: index + 1,
332
+ column: column,
333
+ message: `Avoid fixed timeout ${match} in E2E tests. ${suggestion}`,
334
+ severity: "warning",
335
+ ruleId: this.ruleId,
336
+ evidence: line.trim(),
337
+ suggestion: suggestion
338
+ });
339
+ });
340
+ }
341
+ });
342
+ });
343
+
344
+ return violations;
345
+ }
346
+
347
+ /**
348
+ * Main analysis function - Engine interface
349
+ * Expected signature: analyze(files, language, options)
350
+ */
351
+ async analyze(files, language, options = {}) {
352
+ const allViolations = [];
353
+
354
+ for (const filePath of files) {
355
+ try {
356
+ // Only analyze test files
357
+ if (!this.isTestFile(filePath)) {
358
+ continue;
359
+ }
360
+
361
+ // Read file content
362
+ const content = require('fs').readFileSync(filePath, 'utf8');
363
+
364
+ // Skip if file has explicit allow annotation
365
+ if (this.hasAllowAnnotation(content)) {
366
+ continue;
367
+ }
368
+
369
+ let violations = [];
370
+
371
+ // Detect timer API violations
372
+ violations = violations.concat(this.detectTimerViolations(content, filePath));
373
+
374
+ // Detect busy polling violations
375
+ violations = violations.concat(this.detectBusyPollingViolations(content, filePath));
376
+
377
+ // Detect E2E violations (if file looks like E2E test)
378
+ if (filePath.includes('e2e') || content.includes('playwright') || content.includes('cypress')) {
379
+ violations = violations.concat(this.detectE2EViolations(content, filePath));
380
+ }
381
+
382
+ allViolations.push(...violations);
383
+
384
+ } catch (error) {
385
+ console.warn(`C070 analysis error for ${filePath}:`, error.message);
386
+ }
387
+ }
388
+
389
+ return allViolations;
390
+ }
391
+
392
+ /**
393
+ * Legacy analysis function for single file
394
+ * @deprecated Use analyze(files, language, options) instead
395
+ */
396
+ analyzeSingleFile(content, filePath) {
397
+ // Only analyze test files
398
+ if (!this.isTestFile(filePath)) {
399
+ return [];
400
+ }
401
+
402
+ let violations = [];
403
+
404
+ try {
405
+ // Detect timer API violations
406
+ violations = violations.concat(this.detectTimerViolations(content, filePath));
407
+
408
+ // Detect busy polling violations
409
+ violations = violations.concat(this.detectBusyPollingViolations(content, filePath));
410
+
411
+ // Detect E2E violations (if file looks like E2E test)
412
+ if (filePath.includes('e2e') || content.includes('playwright') || content.includes('cypress')) {
413
+ violations = violations.concat(this.detectE2EViolations(content, filePath));
414
+ }
415
+
416
+ } catch (error) {
417
+ console.warn(`C070 analysis error for ${filePath}:`, error.message);
418
+ }
419
+
420
+ return violations;
421
+ }
422
+ }
423
+
424
+ module.exports = C070TestRealTimeAnalyzer;