@rigour-labs/core 4.2.3 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Side-Effect Analysis Gate
3
+ *
4
+ * Context-aware detection of code patterns with real-world consequences:
5
+ * unbounded process spawns, runaway timers, missing circuit breakers,
6
+ * circular file watchers, resource leaks, and auto-restart bombs.
7
+ *
8
+ * SMART DETECTION APPROACH (not hardcoded regex):
9
+ * ───────────────────────────────────────────────
10
+ * 1. VARIABLE BINDING TRACKING: Tracks `const id = setInterval(...)` and
11
+ * verifies `clearInterval(id)` exists — not just "does clearInterval
12
+ * appear somewhere in the file?"
13
+ *
14
+ * 2. SCOPE-AWARE ANALYSIS: Checks cleanup is in the same function scope
15
+ * as creation. A clearInterval in a completely different function doesn't
16
+ * help the one that created the timer.
17
+ *
18
+ * 3. FRAMEWORK AWARENESS: Understands React useEffect cleanup returns,
19
+ * Go `defer f.Close()` idiom, Python `with open()` context managers,
20
+ * Java try-with-resources, C# using statements, Ruby block form.
21
+ *
22
+ * 4. PATH OVERLAP ANALYSIS: For circular triggers, extracts actual paths
23
+ * from watch() and writeFile() calls and checks if they overlap.
24
+ *
25
+ * 5. BASE CASE ORDERING: For recursion, checks that the base case
26
+ * (return/break) comes BEFORE the recursive call, not just that
27
+ * both exist somewhere in the function.
28
+ *
29
+ * Follows the architectural patterns of:
30
+ * - hallucinated-imports (build context → scan → resolve → report)
31
+ * - promise-safety (scope-aware helpers, extractBraceBody, isInsideTryBlock)
32
+ *
33
+ * This is a CORE gate (enabled by default, provenance: ai-drift).
34
+ * Supports: JS/TS, Python, Go, Rust, C#, Java, Ruby
35
+ *
36
+ * @since v4.3.0
37
+ */
38
+ import { Gate } from './base.js';
39
+ import { FileScanner } from '../utils/scanner.js';
40
+ import { Logger } from '../utils/logger.js';
41
+ import fs from 'fs-extra';
42
+ import path from 'path';
43
+ import { LANG_MAP, FILE_GLOBS, stripStrings,
44
+ // Scope analysis
45
+ findEnclosingFunction, extractLoopBody, extractFunctionDefs,
46
+ // Variable binding
47
+ extractVariableBinding, hasCleanupForVariable,
48
+ // Framework awareness
49
+ isInUseEffectWithCleanup, hasGoDefer, isPythonWithStatement, isJavaTryWithResources, isCSharpUsing, isRubyBlockForm, isRustAutoDropped, isInsideCleanupContext,
50
+ // Detectors
51
+ isTimerCreation, getTimerCleanupPatterns, isProcessSpawn, getProcessCleanupPatterns, isUnboundedLoop, containsIO, isFileWatcher, extractWatchedPath, extractWritePath, pathsOverlap, findWriteInBody, hasDebounceProtection, isResourceOpen, getResourceClosePatterns, isExitHandler,
52
+ // Loop/recursion
53
+ hasRetryLimit, hasCatchWithContinue, hasBaseCase, hasDepthParameter, } from './side-effect-helpers.js';
54
+ export class SideEffectAnalysisGate extends Gate {
55
+ cfg;
56
+ constructor(config = {}) {
57
+ super('side-effect-analysis', 'Side-Effect Safety Analysis');
58
+ this.cfg = {
59
+ enabled: config.enabled ?? true,
60
+ check_unbounded_timers: config.check_unbounded_timers ?? true,
61
+ check_unbounded_loops: config.check_unbounded_loops ?? true,
62
+ check_process_lifecycle: config.check_process_lifecycle ?? true,
63
+ check_recursive_depth: config.check_recursive_depth ?? true,
64
+ check_resource_lifecycle: config.check_resource_lifecycle ?? true,
65
+ check_retry_without_limit: config.check_retry_without_limit ?? true,
66
+ check_circular_triggers: config.check_circular_triggers ?? true,
67
+ check_auto_restart: config.check_auto_restart ?? true,
68
+ ignore_patterns: config.ignore_patterns ?? [],
69
+ };
70
+ }
71
+ get provenance() { return 'ai-drift'; }
72
+ async run(context) {
73
+ if (!this.cfg.enabled)
74
+ return [];
75
+ const violations = [];
76
+ const files = await FileScanner.findFiles({
77
+ cwd: context.cwd,
78
+ patterns: FILE_GLOBS,
79
+ ignore: [
80
+ ...(context.ignore || []),
81
+ '**/node_modules/**', '**/dist/**', '**/build/**',
82
+ '**/*.test.*', '**/*.spec.*', '**/__tests__/**',
83
+ '**/vendor/**', '**/__pycache__/**', '**/venv/**', '**/.venv/**',
84
+ '**/bin/Debug/**', '**/bin/Release/**', '**/obj/**',
85
+ '**/target/debug/**', '**/target/release/**',
86
+ ],
87
+ });
88
+ Logger.info(`Side-Effect Analysis: Scanning ${files.length} files`);
89
+ for (const file of files) {
90
+ if (this.cfg.ignore_patterns.some(p => new RegExp(p).test(file)))
91
+ continue;
92
+ const ext = path.extname(file).toLowerCase();
93
+ const lang = LANG_MAP[ext];
94
+ if (!lang)
95
+ continue;
96
+ try {
97
+ const fullPath = path.join(context.cwd, file);
98
+ const content = await fs.readFile(fullPath, 'utf-8');
99
+ const lines = content.split('\n');
100
+ this.scanFile(lang, lines, content, file, violations);
101
+ }
102
+ catch { /* skip unreadable files */ }
103
+ }
104
+ return this.buildFailures(violations);
105
+ }
106
+ scanFile(lang, lines, content, file, violations) {
107
+ if (this.cfg.check_unbounded_timers) {
108
+ this.checkUnboundedTimers(lang, lines, file, violations);
109
+ }
110
+ if (this.cfg.check_process_lifecycle) {
111
+ this.checkProcessLifecycle(lang, lines, file, violations);
112
+ }
113
+ if (this.cfg.check_unbounded_loops) {
114
+ this.checkUnboundedLoops(lang, lines, file, violations);
115
+ }
116
+ if (this.cfg.check_retry_without_limit) {
117
+ this.checkRetryWithoutLimit(lang, lines, file, violations);
118
+ }
119
+ if (this.cfg.check_circular_triggers) {
120
+ this.checkCircularTriggers(lang, lines, file, violations);
121
+ }
122
+ if (this.cfg.check_resource_lifecycle) {
123
+ this.checkResourceLifecycle(lang, lines, file, violations);
124
+ }
125
+ if (this.cfg.check_recursive_depth) {
126
+ this.checkRecursiveDepth(lang, lines, file, violations);
127
+ }
128
+ if (this.cfg.check_auto_restart) {
129
+ this.checkAutoRestart(lang, lines, file, violations);
130
+ }
131
+ }
132
+ // ═══════════════════════════════════════════════════════════════
133
+ // CHECK 1: Unbounded Timers
134
+ //
135
+ // SMART: Tracks the variable binding. Instead of "does clearInterval
136
+ // exist in the file?", checks "is the timer variable cleaned up in
137
+ // the same scope, or in a React useEffect cleanup return?"
138
+ // ═══════════════════════════════════════════════════════════════
139
+ checkUnboundedTimers(lang, lines, file, violations) {
140
+ const cleanupPats = getTimerCleanupPatterns(lang);
141
+ if (cleanupPats.length === 0)
142
+ return; // Language has no timer API
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const timerCall = isTimerCreation(lines[i], lang);
145
+ if (!timerCall)
146
+ continue;
147
+ // Extract variable binding: `const timer = setInterval(...)`
148
+ const varName = extractVariableBinding(lines[i], lang);
149
+ // Find enclosing function scope
150
+ const scope = findEnclosingFunction(lines, i, lang);
151
+ // FRAMEWORK CHECK: React useEffect with cleanup return
152
+ if ((lang === 'js' || lang === 'ts') && isInUseEffectWithCleanup(lines, i)) {
153
+ continue; // useEffect cleanup handles it
154
+ }
155
+ // FRAMEWORK CHECK: Inside a cleanup/teardown context already
156
+ if (isInsideCleanupContext(lines, i, lang)) {
157
+ continue; // Timer in cleanup = intentional short-lived timer
158
+ }
159
+ if (varName) {
160
+ // Variable is stored — check for cleanup using that specific variable
161
+ const hasPairedCleanup = hasCleanupForVariable(lines, varName, scope.start, scope.end, cleanupPats, lang);
162
+ if (hasPairedCleanup)
163
+ continue; // Properly paired
164
+ }
165
+ else {
166
+ // Fire-and-forget timer (no variable binding) — most dangerous
167
+ // But check if there's ANY cleanup in the same scope (lenient)
168
+ const scopeBody = lines.slice(scope.start, scope.end).join('\n');
169
+ if (cleanupPats.some(cp => cp.test(scopeBody)))
170
+ continue;
171
+ }
172
+ violations.push({
173
+ rule: 'unbounded-timer',
174
+ severity: 'high',
175
+ file, line: i + 1,
176
+ match: lines[i].trim().substring(0, 100),
177
+ description: varName
178
+ ? `Timer '${varName} = ${timerCall}(...)' created but ` +
179
+ `'${varName}' is never passed to a cleanup function ` +
180
+ `(${cleanupPats.map(p => p.source.match(/\w+/)?.[0]).filter(Boolean).join('/')}) ` +
181
+ `in the same scope.`
182
+ : `${timerCall}() called without storing the return value. ` +
183
+ `The timer runs indefinitely with no way to stop it.`,
184
+ hint: varName
185
+ ? `Call cleanup with the specific variable: e.g. clearInterval(${varName})`
186
+ : `Store the timer: const id = ${timerCall}(...); then clear it in cleanup.`,
187
+ });
188
+ }
189
+ }
190
+ // ═══════════════════════════════════════════════════════════════
191
+ // CHECK 2: Process Lifecycle (orphan processes)
192
+ //
193
+ // SMART: Tracks the spawned process variable. Checks that the
194
+ // specific process has exit handling, .wait(), or .kill() — not
195
+ // just that these words appear somewhere nearby.
196
+ // ═══════════════════════════════════════════════════════════════
197
+ checkProcessLifecycle(lang, lines, file, violations) {
198
+ const cleanupPats = getProcessCleanupPatterns(lang);
199
+ for (let i = 0; i < lines.length; i++) {
200
+ const spawnMatch = isProcessSpawn(lines[i], lang);
201
+ if (!spawnMatch)
202
+ continue;
203
+ const varName = extractVariableBinding(lines[i], lang);
204
+ const scope = findEnclosingFunction(lines, i, lang);
205
+ // Check if the process result is awaited on the same line
206
+ if (/\bawait\b/.test(lines[i]))
207
+ continue;
208
+ // Check if the process is returned (caller handles lifecycle)
209
+ const nextLines = lines.slice(i, Math.min(lines.length, i + 3)).join(' ');
210
+ if (/\breturn\b/.test(nextLines))
211
+ continue;
212
+ // Go: check for deferred cleanup
213
+ if (lang === 'go' && varName && hasGoDefer(lines, i, varName))
214
+ continue;
215
+ // Python subprocess.run() and check_output() are synchronous — safe
216
+ if (lang === 'py' && /\.(?:run|check_output|check_call)\s*\(/.test(lines[i]))
217
+ continue;
218
+ // Rust Command::new().output() is synchronous
219
+ if (lang === 'rs' && /\.output\s*\(\)/.test(lines.slice(i, i + 2).join(' ')))
220
+ continue;
221
+ if (varName) {
222
+ // Check for cleanup on the specific process variable
223
+ const hasPairedCleanup = hasCleanupForVariable(lines, varName, scope.start, scope.end, cleanupPats, lang);
224
+ if (hasPairedCleanup)
225
+ continue;
226
+ }
227
+ else {
228
+ // Process not stored — check if synchronous call (exec is often sync)
229
+ if (lang === 'py' && /\bos\.system\s*\(/.test(lines[i]))
230
+ continue;
231
+ if ((lang === 'js' || lang === 'ts') && /\bexecSync\s*\(/.test(lines[i]))
232
+ continue;
233
+ }
234
+ violations.push({
235
+ rule: 'orphan-process',
236
+ severity: 'high',
237
+ file, line: i + 1,
238
+ match: lines[i].trim().substring(0, 100),
239
+ description: varName
240
+ ? `Process '${varName}' spawned but no exit/cleanup handling found ` +
241
+ `for '${varName}' in the same scope. If the process hangs, it becomes ` +
242
+ `an orphan consuming resources.`
243
+ : `Process spawned without storing handle. No way to monitor or kill it.`,
244
+ hint: lang === 'py'
245
+ ? `Add '${varName || 'proc'}.wait()' or use 'with' context manager.`
246
+ : lang === 'go'
247
+ ? `Add 'defer ${varName || 'cmd'}.Process.Kill()' or '${varName || 'cmd'}.Wait()'.`
248
+ : `Add '.on("exit", handler)' or '.kill()' in error path.`,
249
+ });
250
+ }
251
+ }
252
+ // ═══════════════════════════════════════════════════════════════
253
+ // CHECK 3: Unbounded Loops with I/O
254
+ //
255
+ // SMART: Extracts the actual loop body using scope-aware block
256
+ // detection (brace/indent tracking), then checks for I/O ops
257
+ // within that body specifically. Understands Go's `for { select {} }`
258
+ // server pattern as safe.
259
+ // ═══════════════════════════════════════════════════════════════
260
+ checkUnboundedLoops(lang, lines, file, violations) {
261
+ for (let i = 0; i < lines.length; i++) {
262
+ if (!isUnboundedLoop(lines[i], lang))
263
+ continue;
264
+ // Extract actual loop body using scope tracking
265
+ const { body, end } = extractLoopBody(lines, i, lang);
266
+ // Check for I/O inside the extracted body
267
+ if (!containsIO(body, lang))
268
+ continue;
269
+ // Check for exit conditions within the body
270
+ const hasBreak = /\b(?:break|return)\b/.test(body);
271
+ const hasSleep = /\b(?:sleep|delay|setTimeout|time\.Sleep|asyncio\.sleep|Thread\.sleep|Task\.Delay)\b/i.test(body);
272
+ // Go: `for { select {} }` is idiomatic server pattern
273
+ if (lang === 'go' && /\bselect\s*\{/.test(body))
274
+ continue;
275
+ // Go: `for { ... case <-ctx.Done(): return }` has context cancellation
276
+ if (lang === 'go' && /ctx\.Done\(\)/.test(body))
277
+ continue;
278
+ // Python: `while True: ... if condition: break` is common pattern
279
+ if (hasBreak)
280
+ continue;
281
+ // Sleep/delay indicates intentional polling (not a tight spin loop)
282
+ if (hasSleep)
283
+ continue;
284
+ violations.push({
285
+ rule: 'unbounded-io-loop',
286
+ severity: 'critical',
287
+ file, line: i + 1,
288
+ match: lines[i].trim().substring(0, 100),
289
+ description: `Infinite loop with I/O operations and no exit condition or delay. ` +
290
+ `This will consume CPU and I/O resources indefinitely.`,
291
+ hint: 'Add a break condition, max iteration count, or sleep/backoff between iterations.',
292
+ });
293
+ }
294
+ }
295
+ // ═══════════════════════════════════════════════════════════════
296
+ // CHECK 4: Retry Without Limit
297
+ //
298
+ // SMART: Looks for loops with catch/except that continue
299
+ // (actual retry pattern), not just "catch exists in a loop".
300
+ // Checks BOTH the loop body AND preamble (10 lines above)
301
+ // for retry counter declarations.
302
+ // ═══════════════════════════════════════════════════════════════
303
+ checkRetryWithoutLimit(lang, lines, file, violations) {
304
+ for (let i = 0; i < lines.length; i++) {
305
+ // We check both unbounded loops and while(condition) loops
306
+ const stripped = stripStrings(lines[i]);
307
+ const isLoop = isUnboundedLoop(lines[i], lang) || /\bwhile\s*\(/.test(stripped);
308
+ if (!isLoop)
309
+ continue;
310
+ const { body, end } = extractLoopBody(lines, i, lang);
311
+ // Check if this is actually a retry pattern (catch + continue)
312
+ if (!hasCatchWithContinue(body, lang))
313
+ continue;
314
+ // Check for retry limits in body AND preamble
315
+ if (hasRetryLimit(lines, i, end))
316
+ continue;
317
+ violations.push({
318
+ rule: 'retry-without-limit',
319
+ severity: 'high',
320
+ file, line: i + 1,
321
+ match: lines[i].trim().substring(0, 100),
322
+ description: `Retry loop (catch + continue) without maximum attempt limit. ` +
323
+ `If the operation keeps failing, this retries indefinitely.`,
324
+ hint: 'Add a maxRetries counter, exponential backoff, or circuit breaker pattern.',
325
+ });
326
+ }
327
+ }
328
+ // ═══════════════════════════════════════════════════════════════
329
+ // CHECK 5: Circular File Triggers (watch → write → watch → ...)
330
+ //
331
+ // SMART: Extracts the ACTUAL paths from watch() and writeFile()
332
+ // calls, then checks if the write target is inside the watched
333
+ // directory. Not just "write exists in watcher callback".
334
+ // ═══════════════════════════════════════════════════════════════
335
+ checkCircularTriggers(lang, lines, file, violations) {
336
+ for (let i = 0; i < lines.length; i++) {
337
+ const watcherCall = isFileWatcher(lines[i], lang);
338
+ if (!watcherCall)
339
+ continue;
340
+ // Extract the watched path
341
+ const watchedPath = extractWatchedPath(lines[i]);
342
+ // Extract the watcher callback body
343
+ const { body, end } = extractLoopBody(lines, i, lang);
344
+ // Check for file write operations in the callback body
345
+ const writeCall = findWriteInBody(body, lang);
346
+ if (!writeCall)
347
+ continue;
348
+ // Extract write target path and check overlap with watched path
349
+ const bodyLines = body.split('\n');
350
+ let writePath = null;
351
+ for (const bl of bodyLines) {
352
+ writePath = extractWritePath(bl);
353
+ if (writePath)
354
+ break;
355
+ }
356
+ // If we can determine paths, check if they overlap
357
+ const definiteOverlap = pathsOverlap(watchedPath, writePath);
358
+ // Even without path overlap detection, write inside a watch is suspicious
359
+ // Check for debounce/throttle protection
360
+ if (hasDebounceProtection(body))
361
+ continue;
362
+ // If paths are known and don't overlap, skip
363
+ if (watchedPath && writePath && !watchedPath.startsWith('$') &&
364
+ !writePath.startsWith('$') && !definiteOverlap) {
365
+ continue;
366
+ }
367
+ const pathInfo = watchedPath && !watchedPath.startsWith('$')
368
+ ? ` on '${watchedPath}'`
369
+ : '';
370
+ const writeInfo = writePath && !writePath.startsWith('$')
371
+ ? ` to '${writePath}'`
372
+ : '';
373
+ violations.push({
374
+ rule: 'circular-trigger',
375
+ severity: 'critical',
376
+ file, line: i + 1,
377
+ match: lines[i].trim().substring(0, 100),
378
+ description: `File watcher${pathInfo} writes${writeInfo} in its callback ` +
379
+ `without debounce protection. ` +
380
+ (definiteOverlap
381
+ ? `The write target is inside the watched directory — this WILL create ` +
382
+ `an infinite trigger loop (watch → write → watch → ...).`
383
+ : `This risks an infinite trigger loop if the write target overlaps ` +
384
+ `with the watched path.`),
385
+ hint: 'Add debouncing, a processing flag, or write to a path outside the watched directory.',
386
+ });
387
+ }
388
+ }
389
+ // ═══════════════════════════════════════════════════════════════
390
+ // CHECK 6: Resource Lifecycle (open without close)
391
+ //
392
+ // SMART: Uses variable binding to pair SPECIFIC open/close calls.
393
+ // Understands language-specific safe patterns:
394
+ // Go: defer f.Close() (checks the specific variable)
395
+ // Python: with open() context manager
396
+ // Java: try-with-resources
397
+ // C#: using statement
398
+ // Ruby: block form
399
+ // Rust: RAII (auto-drop in safe code)
400
+ // ═══════════════════════════════════════════════════════════════
401
+ checkResourceLifecycle(lang, lines, file, violations) {
402
+ const closePats = getResourceClosePatterns(lang);
403
+ for (let i = 0; i < lines.length; i++) {
404
+ const resourceCall = isResourceOpen(lines[i], lang);
405
+ if (!resourceCall)
406
+ continue;
407
+ // ── FRAMEWORK-AWARE SAFE PATTERN DETECTION ──
408
+ // Python: `with open(...)` is safe
409
+ if (lang === 'py' && isPythonWithStatement(lines[i]))
410
+ continue;
411
+ // Go: check for `defer varName.Close()` within 5 lines
412
+ const varName = extractVariableBinding(lines[i], lang);
413
+ if (lang === 'go' && varName && hasGoDefer(lines, i, varName))
414
+ continue;
415
+ // Java: try-with-resources
416
+ if (lang === 'java' && isJavaTryWithResources(lines, i))
417
+ continue;
418
+ // C#: using statement
419
+ if (lang === 'cs' && isCSharpUsing(lines[i]))
420
+ continue;
421
+ // Ruby: block form (auto-closes)
422
+ if (lang === 'rb' && isRubyBlockForm(lines[i]))
423
+ continue;
424
+ // Rust: RAII handles it in safe code
425
+ if (lang === 'rs' && isRustAutoDropped(lines, i))
426
+ continue;
427
+ // Inside a cleanup context (finally, __exit__, Dispose, etc.)
428
+ if (isInsideCleanupContext(lines, i, lang))
429
+ continue;
430
+ // ── VARIABLE-BOUND CLEANUP CHECK ──
431
+ const scope = findEnclosingFunction(lines, i, lang);
432
+ if (varName) {
433
+ // Check for cleanup using the specific variable
434
+ const hasPairedCleanup = hasCleanupForVariable(lines, varName, scope.start, scope.end, closePats, lang);
435
+ if (hasPairedCleanup)
436
+ continue;
437
+ }
438
+ else {
439
+ // No variable — check if any close exists in scope (lenient)
440
+ const scopeBody = lines.slice(scope.start, scope.end).join('\n');
441
+ if (closePats.some(cp => cp.test(scopeBody)))
442
+ continue;
443
+ }
444
+ violations.push({
445
+ rule: 'resource-leak',
446
+ severity: 'medium',
447
+ file, line: i + 1,
448
+ match: lines[i].trim().substring(0, 100),
449
+ description: varName
450
+ ? `Resource '${varName}' opened via ${resourceCall}() but ` +
451
+ `'${varName}.close()' not found in the same scope. ` +
452
+ `This leaks file handles, exhausting OS limits under load.`
453
+ : `Resource opened via ${resourceCall}() but result not stored. ` +
454
+ `The handle leaks immediately.`,
455
+ hint: lang === 'py' ? 'Use `with open(...)` context manager.'
456
+ : lang === 'go' ? `Add \`defer ${varName || 'f'}.Close()\` immediately after open.`
457
+ : lang === 'cs' ? 'Use `using` statement for auto-disposal.'
458
+ : lang === 'java' ? 'Use try-with-resources: `try (var f = ...) { ... }`.'
459
+ : lang === 'rs' ? 'Ensure the handle is not leaked via `std::mem::forget`.'
460
+ : `Close the resource: ${varName || 'handle'}.close() in a finally block.`,
461
+ });
462
+ }
463
+ }
464
+ // ═══════════════════════════════════════════════════════════════
465
+ // CHECK 7: Unbounded Recursion
466
+ //
467
+ // SMART: Extracts function definitions, checks self-calls within
468
+ // the function body (not global), verifies base case comes BEFORE
469
+ // the recursive call, and checks function signature for depth params.
470
+ // Only flags if the recursive function also does I/O (without I/O,
471
+ // it's just a stack overflow — bad but not a side effect).
472
+ // ═══════════════════════════════════════════════════════════════
473
+ checkRecursiveDepth(lang, lines, file, violations) {
474
+ const funcDefs = extractFunctionDefs(lines, lang);
475
+ for (const func of funcDefs) {
476
+ const bodyLines = lines.slice(func.start + 1, func.end);
477
+ const body = bodyLines.join('\n');
478
+ // Check if function calls itself
479
+ const escaped = func.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
480
+ const selfCallPat = new RegExp(`\\b${escaped}\\s*\\(`);
481
+ if (!selfCallPat.test(body))
482
+ continue;
483
+ // Check for depth/limit parameter in function signature
484
+ if (hasDepthParameter(func.params))
485
+ continue;
486
+ // Check for base case BEFORE recursive call (ordering matters)
487
+ if (hasBaseCase(bodyLines, func.name))
488
+ continue;
489
+ // Only flag if there's I/O in the recursive function
490
+ // (pure recursion = stack overflow, not a side-effect issue)
491
+ if (!containsIO(body, lang))
492
+ continue;
493
+ violations.push({
494
+ rule: 'unbounded-recursion',
495
+ severity: 'high',
496
+ file, line: func.start + 1,
497
+ match: lines[func.start].trim().substring(0, 100),
498
+ description: `Recursive function '${func.name}' performs I/O but has no depth ` +
499
+ `bound or base case before the recursive call. ` +
500
+ `Unbounded recursion with I/O will exhaust resources ` +
501
+ `(stack, file handles, network connections).`,
502
+ hint: `Add a depth/maxDepth parameter: ${func.name}(depth = 0) and ` +
503
+ `return when depth exceeds the limit.`,
504
+ });
505
+ }
506
+ }
507
+ // ═══════════════════════════════════════════════════════════════
508
+ // CHECK 8: Auto-Restart / Self-Respawn Bomb
509
+ //
510
+ // SMART: Detects exit/signal handlers that spawn new processes.
511
+ // Extracts the handler body and checks for process spawn calls
512
+ // within it. Verifies restart limits exist.
513
+ // ═══════════════════════════════════════════════════════════════
514
+ checkAutoRestart(lang, lines, file, violations) {
515
+ for (let i = 0; i < lines.length; i++) {
516
+ if (!isExitHandler(lines[i], lang))
517
+ continue;
518
+ // Extract the handler body
519
+ const { body, end } = extractLoopBody(lines, i, lang);
520
+ // Check if the handler spawns a process
521
+ const hasSpawn = body.split('\n').some(l => isProcessSpawn(l, lang) !== null);
522
+ if (!hasSpawn)
523
+ continue;
524
+ // Check for restart limits
525
+ if (hasRetryLimit(lines, i, end))
526
+ continue;
527
+ // Check for delay between restarts
528
+ const hasDelay = /\b(?:sleep|delay|setTimeout|time\.Sleep|asyncio\.sleep|Thread\.sleep|Task\.Delay)\b/i.test(body);
529
+ violations.push({
530
+ rule: 'auto-restart-bomb',
531
+ severity: 'critical',
532
+ file, line: i + 1,
533
+ match: lines[i].trim().substring(0, 100),
534
+ description: `Exit/signal handler spawns a new process without restart limit. ` +
535
+ `If the process crashes on startup, this creates an infinite respawn ` +
536
+ `loop that floods the system with processes.`,
537
+ hint: hasDelay
538
+ ? 'Add a maximum restart count (e.g., max 3 restarts within 5 minutes).'
539
+ : 'Add both a maximum restart count AND a delay between restarts.',
540
+ });
541
+ }
542
+ }
543
+ // ═══════════════════════════════════════════════════════════════
544
+ // OUTPUT
545
+ // ═══════════════════════════════════════════════════════════════
546
+ buildFailures(violations) {
547
+ return violations.map(v => this.createFailure(v.description, [v.file], v.hint, `Side-Effect: ${RULE_TITLES[v.rule] || v.rule}`, v.line, v.line, v.severity));
548
+ }
549
+ }
550
+ const RULE_TITLES = {
551
+ 'unbounded-timer': 'Unbounded Timer',
552
+ 'orphan-process': 'Orphan Process',
553
+ 'unbounded-io-loop': 'Unbounded I/O Loop',
554
+ 'retry-without-limit': 'Retry Without Limit',
555
+ 'circular-trigger': 'Circular File Trigger',
556
+ 'resource-leak': 'Resource Leak',
557
+ 'unbounded-recursion': 'Unbounded Recursion',
558
+ 'auto-restart-bomb': 'Auto-Restart Bomb',
559
+ };