@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,1096 @@
1
+ /**
2
+ * Side-Effect Analysis Helpers
3
+ *
4
+ * Context-aware utilities for smart side-effect detection.
5
+ * Follows the same architectural patterns as promise-safety-helpers.ts:
6
+ * - Scope-aware analysis (brace/indent tracking)
7
+ * - Variable binding tracking (pair resource creation with cleanup)
8
+ * - Framework detection (React useEffect, Go defer, Python with, etc.)
9
+ * - Path overlap analysis (circular file watcher detection)
10
+ *
11
+ * These helpers make side-effect detection SMART — instead of asking
12
+ * "does clearInterval exist anywhere in the file?", we ask
13
+ * "is the specific timer variable cleaned up in the right scope?"
14
+ *
15
+ * @since v4.3.0
16
+ */
17
+ // ── Language detection ──
18
+ export const LANG_MAP = {
19
+ '.ts': 'ts', '.tsx': 'ts', '.mts': 'ts',
20
+ '.js': 'js', '.jsx': 'js', '.mjs': 'js', '.cjs': 'js',
21
+ '.py': 'py',
22
+ '.go': 'go',
23
+ '.rs': 'rs',
24
+ '.cs': 'cs',
25
+ '.java': 'java',
26
+ '.rb': 'rb',
27
+ };
28
+ export const FILE_GLOBS = [
29
+ '**/*.{ts,tsx,mts,js,jsx,mjs,cjs}',
30
+ '**/*.py',
31
+ '**/*.go',
32
+ '**/*.rs',
33
+ '**/*.cs',
34
+ '**/*.java',
35
+ '**/*.rb',
36
+ ];
37
+ // ── Strip string contents to avoid false positives in regex matching ──
38
+ export function stripStrings(line) {
39
+ return line
40
+ .replace(/`[^`]*`/g, '""')
41
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
42
+ .replace(/'(?:[^'\\]|\\.)*'/g, '""');
43
+ }
44
+ // ═══════════════════════════════════════════════════════════════════
45
+ // SCOPE ANALYSIS — Find function/block boundaries
46
+ // ═══════════════════════════════════════════════════════════════════
47
+ /**
48
+ * Find the enclosing function scope for a given line.
49
+ * Returns { start, end } of the function body.
50
+ * For module-level code, returns { start: 0, end: lines.length }.
51
+ *
52
+ * Follows promise-safety's approach of backward scanning with brace tracking.
53
+ */
54
+ export function findEnclosingFunction(lines, lineIdx, lang) {
55
+ if (lang === 'py')
56
+ return findEnclosingFunctionPython(lines, lineIdx);
57
+ if (lang === 'rb')
58
+ return findEnclosingFunctionRuby(lines, lineIdx);
59
+ return findEnclosingFunctionBrace(lines, lineIdx, lang);
60
+ }
61
+ function findEnclosingFunctionBrace(lines, lineIdx, lang) {
62
+ // Walk backwards tracking brace depth to find function definition
63
+ let braceDepth = 0;
64
+ const funcPatterns = getFunctionPatterns(lang);
65
+ for (let j = lineIdx; j >= Math.max(0, lineIdx - 200); j--) {
66
+ const stripped = stripStrings(lines[j]);
67
+ // Count braces (reverse direction: } increases, { decreases)
68
+ for (const ch of stripped) {
69
+ if (ch === '}')
70
+ braceDepth++;
71
+ if (ch === '{')
72
+ braceDepth--;
73
+ }
74
+ // If braceDepth < 0, we've exited the enclosing block going backwards
75
+ if (braceDepth < 0) {
76
+ // Check if this line is a function definition
77
+ for (const pat of funcPatterns) {
78
+ if (pat.test(stripped)) {
79
+ const end = findBlockEndBrace(lines, j);
80
+ return { start: j, end };
81
+ }
82
+ }
83
+ // It's some other block (if/for/etc), keep looking
84
+ braceDepth = 0;
85
+ }
86
+ }
87
+ // Module level
88
+ return { start: 0, end: lines.length };
89
+ }
90
+ function findEnclosingFunctionPython(lines, lineIdx) {
91
+ const lineIndent = lines[lineIdx].length - lines[lineIdx].trimStart().length;
92
+ for (let j = lineIdx - 1; j >= 0; j--) {
93
+ const trimmed = lines[j].trim();
94
+ if (trimmed === '' || trimmed.startsWith('#'))
95
+ continue;
96
+ const indent = lines[j].length - lines[j].trimStart().length;
97
+ if (indent < lineIndent && /^\s*(?:async\s+)?def\s+\w+/.test(lines[j])) {
98
+ const end = findBlockEndIndent(lines, j);
99
+ return { start: j, end };
100
+ }
101
+ if (indent === 0 && /^\s*(?:class|def|async\s+def)\s/.test(lines[j])) {
102
+ const end = findBlockEndIndent(lines, j);
103
+ return { start: j, end };
104
+ }
105
+ }
106
+ return { start: 0, end: lines.length };
107
+ }
108
+ function findEnclosingFunctionRuby(lines, lineIdx) {
109
+ for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 100); j--) {
110
+ const trimmed = lines[j].trim();
111
+ if (/^def\s+\w+/.test(trimmed)) {
112
+ const end = findBlockEndRuby(lines, j);
113
+ return { start: j, end };
114
+ }
115
+ }
116
+ return { start: 0, end: lines.length };
117
+ }
118
+ /**
119
+ * Find the end of a brace-delimited block starting at `start`.
120
+ */
121
+ export function findBlockEndBrace(lines, start) {
122
+ let braces = 0;
123
+ let started = false;
124
+ const maxScan = Math.min(lines.length, start + 300);
125
+ for (let j = start; j < maxScan; j++) {
126
+ const stripped = stripStrings(lines[j]);
127
+ for (const ch of stripped) {
128
+ if (ch === '{') {
129
+ braces++;
130
+ started = true;
131
+ }
132
+ if (ch === '}')
133
+ braces--;
134
+ }
135
+ if (started && braces <= 0)
136
+ return j + 1;
137
+ }
138
+ return maxScan;
139
+ }
140
+ /**
141
+ * Find the end of an indentation-delimited block (Python).
142
+ */
143
+ export function findBlockEndIndent(lines, start) {
144
+ const baseIndent = lines[start].length - lines[start].trimStart().length;
145
+ const maxScan = Math.min(lines.length, start + 300);
146
+ for (let j = start + 1; j < maxScan; j++) {
147
+ const trimmed = lines[j].trim();
148
+ if (trimmed === '' || trimmed.startsWith('#'))
149
+ continue;
150
+ const indent = lines[j].length - lines[j].trimStart().length;
151
+ if (indent <= baseIndent)
152
+ return j;
153
+ }
154
+ return maxScan;
155
+ }
156
+ function findBlockEndRuby(lines, start) {
157
+ let depth = 0;
158
+ const maxScan = Math.min(lines.length, start + 300);
159
+ const openers = /\b(?:def|do|class|module|if|unless|while|until|for|begin|case)\b/;
160
+ for (let j = start; j < maxScan; j++) {
161
+ const trimmed = lines[j].trim();
162
+ if (openers.test(trimmed))
163
+ depth++;
164
+ if (/^\s*end\b/.test(trimmed)) {
165
+ depth--;
166
+ if (depth <= 0)
167
+ return j + 1;
168
+ }
169
+ }
170
+ return maxScan;
171
+ }
172
+ function getFunctionPatterns(lang) {
173
+ switch (lang) {
174
+ case 'go':
175
+ return [/\bfunc\s+/];
176
+ case 'rs':
177
+ return [/\bfn\s+\w+/];
178
+ case 'java':
179
+ case 'cs':
180
+ return [/(?:public|private|protected|static|async|void|int|string|Task|var)\s+\w+\s*\(/];
181
+ default: // js, ts
182
+ return [
183
+ /(?:export\s+)?(?:async\s+)?function\s+\w+/,
184
+ /(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>/,
185
+ /\w+\s*\([^)]*\)\s*\{/, // method shorthand
186
+ ];
187
+ }
188
+ }
189
+ // ═══════════════════════════════════════════════════════════════════
190
+ // VARIABLE BINDING — Track resource creation → cleanup pairs
191
+ // ═══════════════════════════════════════════════════════════════════
192
+ /**
193
+ * Extract the variable name from an assignment.
194
+ * "const timer = setInterval(...)" → "timer"
195
+ * "let fd = fs.open(...)" → "fd"
196
+ * "self.watcher = chokidar.watch()" → "self.watcher"
197
+ * "timer := time.NewTicker(...)" → "timer" (Go)
198
+ *
199
+ * Returns null if the call result is NOT stored in a variable.
200
+ */
201
+ export function extractVariableBinding(line, lang) {
202
+ const stripped = stripStrings(line).trim();
203
+ if (lang === 'go') {
204
+ // Go: `ticker := time.NewTicker(...)` or `ticker, _ := ...`
205
+ const goMatch = stripped.match(/^(\w+)(?:\s*,\s*\w+)*\s*:?=\s*/);
206
+ if (goMatch)
207
+ return goMatch[1];
208
+ return null;
209
+ }
210
+ if (lang === 'py') {
211
+ // Python: `timer = threading.Timer(...)` or `self.timer = ...`
212
+ const pyMatch = stripped.match(/^((?:self\.)?[\w.]+)\s*=\s*(?!==)/);
213
+ if (pyMatch)
214
+ return pyMatch[1];
215
+ return null;
216
+ }
217
+ if (lang === 'rb') {
218
+ // Ruby: `@watcher = Listen.to(...)` or `watcher = ...`
219
+ const rbMatch = stripped.match(/^(@?\w+)\s*=\s*/);
220
+ if (rbMatch)
221
+ return rbMatch[1];
222
+ return null;
223
+ }
224
+ // JS/TS/Java/C#/Rust: `const x = ...`, `let x = ...`, `var x = ...`, `auto x = ...`
225
+ const jsMatch = stripped.match(/^(?:const|let|var|final|auto|val)\s+(\w+)\s*=\s*/);
226
+ if (jsMatch)
227
+ return jsMatch[1];
228
+ // Member assignment: `this.timer = ...`, `self.timer = ...`
229
+ const memberMatch = stripped.match(/^(?:this|self)\.([\w]+)\s*=\s*/);
230
+ if (memberMatch)
231
+ return memberMatch[1];
232
+ // Simple assignment: `timer = ...`
233
+ const simpleMatch = stripped.match(/^(\w+)\s*=\s*(?!==)/);
234
+ if (simpleMatch) {
235
+ // Exclude control flow keywords
236
+ const name = simpleMatch[1];
237
+ if (['if', 'for', 'while', 'switch', 'return', 'throw'].includes(name))
238
+ return null;
239
+ return name;
240
+ }
241
+ return null;
242
+ }
243
+ /**
244
+ * Check if a specific variable is used in a cleanup call within a scope.
245
+ *
246
+ * Unlike the naive "does clearInterval exist in the file?", this checks:
247
+ * 1. The cleanup function references the specific variable
248
+ * 2. The cleanup is within the correct scope (same function or cleanup callback)
249
+ *
250
+ * Example: for variable "timer" and cleanup patterns [/clearInterval/],
251
+ * matches: `clearInterval(timer)`, `clearInterval(this.timer)`, `timer.close()`
252
+ */
253
+ export function hasCleanupForVariable(lines, varName, scopeStart, scopeEnd, cleanupPatterns, lang) {
254
+ const scope = lines.slice(scopeStart, scopeEnd);
255
+ for (let i = 0; i < scope.length; i++) {
256
+ const stripped = stripStrings(scope[i]);
257
+ // Check cleanup patterns that reference the specific variable
258
+ for (const pat of cleanupPatterns) {
259
+ if (!pat.test(stripped))
260
+ continue;
261
+ // The cleanup call should reference our variable
262
+ const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
263
+ const varRef = new RegExp(`\\b${escapedVar}\\b`);
264
+ if (varRef.test(stripped))
265
+ return true;
266
+ // Also check method calls on the variable: timer.close(), timer.stop()
267
+ // The pattern might match a generic .close() — check if it's on our var
268
+ }
269
+ // Direct method cleanup on the variable: varName.close(), varName.Stop()
270
+ const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
271
+ const methodCleanup = new RegExp(`\\b${escapedVar}\\.(?:close|stop|destroy|kill|terminate|dispose|cancel|shutdown|unsubscribe|disconnect|end|release|Clear|Stop|Dispose|Close|Cancel)\\s*\\(`);
272
+ if (methodCleanup.test(stripped))
273
+ return true;
274
+ }
275
+ return false;
276
+ }
277
+ /**
278
+ * Check if a line is inside a cleanup/teardown context.
279
+ *
280
+ * Cleanup contexts where resource cleanup is expected:
281
+ * - JS/TS: useEffect return function, componentWillUnmount, beforeDestroy, ngOnDestroy, dispose()
282
+ * - Python: __del__, __exit__, close(), cleanup(), teardown
283
+ * - Go: defer statement
284
+ * - Java: finally block, close() method, @PreDestroy
285
+ * - C#: Dispose(), using block, finalizer
286
+ * - Ruby: ensure block, at_exit
287
+ */
288
+ export function isInsideCleanupContext(lines, lineIdx, lang) {
289
+ // Scan backwards up to 30 lines for cleanup context markers
290
+ for (let j = lineIdx; j >= Math.max(0, lineIdx - 30); j--) {
291
+ const trimmed = lines[j].trim();
292
+ switch (lang) {
293
+ case 'js':
294
+ case 'ts':
295
+ // React useEffect cleanup: `return () => { cleanup }`
296
+ if (/\breturn\s+(?:\(\)\s*=>|function\s*\()/.test(trimmed))
297
+ return true;
298
+ // Lifecycle: componentWillUnmount, ngOnDestroy, beforeDestroy
299
+ if (/\b(?:componentWillUnmount|ngOnDestroy|beforeDestroy|dispose)\s*\(/.test(trimmed))
300
+ return true;
301
+ // Event: 'beforeunload', 'unload'
302
+ if (/['"](?:beforeunload|unload)['"]\s*,/.test(trimmed))
303
+ return true;
304
+ break;
305
+ case 'py':
306
+ if (/\bdef\s+(?:__del__|__exit__|close|cleanup|teardown|dispose)\s*\(/.test(trimmed))
307
+ return true;
308
+ if (/\bfinally\s*:/.test(trimmed))
309
+ return true;
310
+ break;
311
+ case 'go':
312
+ if (/\bdefer\b/.test(trimmed))
313
+ return true;
314
+ break;
315
+ case 'java':
316
+ if (/\bfinally\s*\{/.test(trimmed))
317
+ return true;
318
+ if (/\b(?:close|destroy|cleanup|dispose)\s*\(/.test(trimmed))
319
+ return true;
320
+ if (/@PreDestroy/.test(trimmed))
321
+ return true;
322
+ break;
323
+ case 'cs':
324
+ if (/\bDispose\s*\(/.test(trimmed))
325
+ return true;
326
+ if (/\busing\s*\(/.test(trimmed))
327
+ return true;
328
+ if (/~\w+\s*\(/.test(trimmed))
329
+ return true; // finalizer
330
+ break;
331
+ case 'rb':
332
+ if (/\bensure\b/.test(trimmed))
333
+ return true;
334
+ break;
335
+ case 'rs':
336
+ if (/\bimpl\s+Drop\b/.test(trimmed))
337
+ return true;
338
+ break;
339
+ }
340
+ // Stop at function boundaries
341
+ if (isFunctionBoundary(trimmed, lang))
342
+ break;
343
+ }
344
+ return false;
345
+ }
346
+ // ═══════════════════════════════════════════════════════════════════
347
+ // FRAMEWORK-AWARE PATTERNS — Detect safe idioms per ecosystem
348
+ // ═══════════════════════════════════════════════════════════════════
349
+ /**
350
+ * Check if a timer/resource creation is inside a React useEffect
351
+ * that returns a cleanup function.
352
+ *
353
+ * Pattern:
354
+ * useEffect(() => {
355
+ * const timer = setInterval(...) ← creation
356
+ * return () => clearInterval(timer) ← cleanup
357
+ * }, [deps])
358
+ */
359
+ export function isInUseEffectWithCleanup(lines, lineIdx) {
360
+ // Walk backwards to find useEffect
361
+ let braceDepth = 0;
362
+ for (let j = lineIdx; j >= Math.max(0, lineIdx - 30); j--) {
363
+ const stripped = stripStrings(lines[j]);
364
+ for (const ch of stripped) {
365
+ if (ch === '}')
366
+ braceDepth++;
367
+ if (ch === '{')
368
+ braceDepth--;
369
+ }
370
+ if (/\buseEffect\s*\(/.test(stripped) && braceDepth <= 0) {
371
+ // Found enclosing useEffect — now check if it has a return () => ...
372
+ const effectEnd = findBlockEndBrace(lines, j);
373
+ const effectBody = lines.slice(j, effectEnd).join('\n');
374
+ // Look for cleanup return: `return () =>` or `return function`
375
+ if (/\breturn\s+(?:\(\)\s*=>|function\s*\()/.test(effectBody)) {
376
+ return true;
377
+ }
378
+ return false;
379
+ }
380
+ }
381
+ return false;
382
+ }
383
+ /**
384
+ * Check if a Go resource open is immediately followed by defer close.
385
+ *
386
+ * Idiomatic Go:
387
+ * f, err := os.Open(path)
388
+ * if err != nil { return err }
389
+ * defer f.Close()
390
+ */
391
+ export function hasGoDefer(lines, openLine, varName) {
392
+ // Check lines between open and open+5 for defer using the variable
393
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
394
+ const deferPat = new RegExp(`\\bdefer\\s+${escaped}\\.`);
395
+ for (let j = openLine + 1; j < Math.min(lines.length, openLine + 6); j++) {
396
+ if (deferPat.test(lines[j]))
397
+ return true;
398
+ // Also match: defer func() { varName.Close() }()
399
+ if (/\bdefer\s+func\s*\(\)/.test(lines[j])) {
400
+ const endDefer = findBlockEndBrace(lines, j);
401
+ const body = lines.slice(j, endDefer).join('\n');
402
+ if (new RegExp(`\\b${escaped}\\.`).test(body))
403
+ return true;
404
+ }
405
+ }
406
+ return false;
407
+ }
408
+ /**
409
+ * Check if a Python open() is inside a `with` statement (context manager).
410
+ */
411
+ export function isPythonWithStatement(line) {
412
+ return /\bwith\s+/.test(stripStrings(line));
413
+ }
414
+ /**
415
+ * Check if a Java resource open is inside try-with-resources.
416
+ * Pattern: try (var x = new FileStream(...)) { ... }
417
+ */
418
+ export function isJavaTryWithResources(lines, lineIdx) {
419
+ for (let j = lineIdx; j >= Math.max(0, lineIdx - 3); j--) {
420
+ if (/\btry\s*\(/.test(stripStrings(lines[j])))
421
+ return true;
422
+ }
423
+ return false;
424
+ }
425
+ /**
426
+ * Check if a C# resource is inside a using statement/declaration.
427
+ * Patterns: `using (var x = ...)` or `using var x = ...` (C# 8+)
428
+ */
429
+ export function isCSharpUsing(line) {
430
+ const stripped = stripStrings(line);
431
+ return /\busing\s*\(/.test(stripped) || /\busing\s+(?:var|await)\b/.test(stripped);
432
+ }
433
+ /**
434
+ * Check if a Ruby File.open uses block form (auto-closes).
435
+ * Pattern: File.open(path) do |f| ... end
436
+ * File.open(path) { |f| ... }
437
+ */
438
+ export function isRubyBlockForm(line) {
439
+ return /\bdo\s*\|/.test(line) || /\{\s*\|/.test(line);
440
+ }
441
+ /**
442
+ * Check if a Rust resource is automatically dropped (RAII).
443
+ * In Rust, all resources are dropped when they go out of scope,
444
+ * so we only flag resources in unsafe blocks or static/global context.
445
+ */
446
+ export function isRustAutoDropped(lines, lineIdx) {
447
+ // Check if inside unsafe block (manual memory management)
448
+ for (let j = lineIdx; j >= Math.max(0, lineIdx - 20); j--) {
449
+ if (/\bunsafe\s*\{/.test(lines[j]))
450
+ return false; // Not auto-dropped in unsafe
451
+ }
452
+ return true; // Normal Rust = RAII applies
453
+ }
454
+ // ═══════════════════════════════════════════════════════════════════
455
+ // CIRCULAR TRIGGER DETECTION — Path overlap analysis
456
+ // ═══════════════════════════════════════════════════════════════════
457
+ /**
458
+ * Extract the path being watched from a file watcher call.
459
+ * Returns null if path cannot be determined.
460
+ *
461
+ * Handles:
462
+ * - fs.watch('./src', ...)
463
+ * - chokidar.watch(['./src', './lib'], ...)
464
+ * - Observer(path=...)
465
+ * - fsnotify.NewWatcher() + watcher.Add(path)
466
+ */
467
+ export function extractWatchedPath(line) {
468
+ // String literal path
469
+ const stringMatch = line.match(/(?:watch|Watch|observe|Add)\s*\(\s*['"]([^'"]+)['"]/);
470
+ if (stringMatch)
471
+ return stringMatch[1];
472
+ // Variable path (best effort — capture the variable name)
473
+ const varMatch = line.match(/(?:watch|Watch|observe|Add)\s*\(\s*(\w+)/);
474
+ if (varMatch)
475
+ return `$${varMatch[1]}`; // Mark as variable reference
476
+ return null;
477
+ }
478
+ /**
479
+ * Extract write target path from a file write call.
480
+ * Returns null if path cannot be determined.
481
+ */
482
+ export function extractWritePath(line) {
483
+ // writeFile/writeFileSync('path', ...) or similar
484
+ const stringMatch = line.match(/(?:writeFile|appendFile|Write|Create|open)\s*(?:Sync)?\s*\(\s*['"]([^'"]+)['"]/);
485
+ if (stringMatch)
486
+ return stringMatch[1];
487
+ // Variable path
488
+ const varMatch = line.match(/(?:writeFile|appendFile|Write|Create)\s*(?:Sync)?\s*\(\s*(\w+)/);
489
+ if (varMatch)
490
+ return `$${varMatch[1]}`;
491
+ return null;
492
+ }
493
+ /**
494
+ * Check if a write path could trigger a file watcher.
495
+ *
496
+ * Smart matching:
497
+ * - "./src/output.js" is inside watched "./src"
498
+ * - "./dist/bundle.js" is NOT inside "./src"
499
+ * - If either path is a variable reference ($var), consider it suspicious
500
+ * - Exact matches always overlap
501
+ */
502
+ export function pathsOverlap(watchPath, writePath) {
503
+ if (!watchPath || !writePath)
504
+ return false;
505
+ // If either is a variable reference, we can't determine — be conservative
506
+ if (watchPath.startsWith('$') || writePath.startsWith('$'))
507
+ return true;
508
+ // Normalize paths
509
+ const normalizeP = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
510
+ const w = normalizeP(watchPath);
511
+ const t = normalizeP(writePath);
512
+ // Exact match
513
+ if (w === t)
514
+ return true;
515
+ // Write target is inside watched directory
516
+ if (t.startsWith(w + '/'))
517
+ return true;
518
+ // Watch target is inside write directory (writing a parent dir)
519
+ if (w.startsWith(t + '/'))
520
+ return true;
521
+ return false;
522
+ }
523
+ // ═══════════════════════════════════════════════════════════════════
524
+ // LOOP & RECURSION ANALYSIS — Context-aware body extraction
525
+ // ═══════════════════════════════════════════════════════════════════
526
+ /**
527
+ * Extract loop body with correct scope tracking.
528
+ * Uses brace/indent matching (not just "next N lines").
529
+ */
530
+ export function extractLoopBody(lines, loopLine, lang) {
531
+ const end = lang === 'py'
532
+ ? findBlockEndIndent(lines, loopLine)
533
+ : lang === 'rb'
534
+ ? findBlockEndRuby(lines, loopLine)
535
+ : findBlockEndBrace(lines, loopLine);
536
+ const body = lines.slice(loopLine, end).join('\n');
537
+ return { body, start: loopLine, end };
538
+ }
539
+ /**
540
+ * Extract all function definitions with their bodies.
541
+ * Used for recursion detection — need to check if function calls itself
542
+ * within its own extracted body (not just anywhere in the file).
543
+ */
544
+ export function extractFunctionDefs(lines, lang) {
545
+ const defs = [];
546
+ const patterns = getFuncDefPatterns(lang);
547
+ for (let i = 0; i < lines.length; i++) {
548
+ const stripped = stripStrings(lines[i]);
549
+ for (const pat of patterns) {
550
+ const m = pat.exec(stripped);
551
+ if (!m)
552
+ continue;
553
+ const name = m[1];
554
+ if (!name || ['if', 'for', 'while', 'switch', 'catch', 'else'].includes(name))
555
+ continue;
556
+ const end = lang === 'py'
557
+ ? findBlockEndIndent(lines, i)
558
+ : lang === 'rb'
559
+ ? findBlockEndRuby(lines, i)
560
+ : findBlockEndBrace(lines, i);
561
+ defs.push({ name, start: i, end, params: lines[i] });
562
+ break;
563
+ }
564
+ }
565
+ return defs;
566
+ }
567
+ function getFuncDefPatterns(lang) {
568
+ switch (lang) {
569
+ case 'py': return [/^\s*(?:async\s+)?def\s+(\w+)\s*\(/];
570
+ case 'go': return [/^func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/];
571
+ case 'rb': return [/^\s*def\s+(\w+)/];
572
+ case 'rs': return [/\bfn\s+(\w+)\s*[(<]/];
573
+ case 'java':
574
+ case 'cs':
575
+ return [/(?:public|private|protected|static|async|void|int|string|Task|var|String|boolean|List|Map)\s+(\w+)\s*\(/];
576
+ default: // js, ts
577
+ return [
578
+ /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/,
579
+ /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>/,
580
+ ];
581
+ }
582
+ }
583
+ /**
584
+ * Check if a function has a base case (return/break before recursive call).
585
+ * Smart: actually checks that the base case comes BEFORE the recursive call,
586
+ * not just that both exist somewhere in the body.
587
+ */
588
+ export function hasBaseCase(bodyLines, funcName) {
589
+ const escapedName = funcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
590
+ const selfCallPat = new RegExp(`\\b${escapedName}\\s*\\(`);
591
+ let foundBaseReturn = false;
592
+ for (const line of bodyLines) {
593
+ const stripped = stripStrings(line);
594
+ // Check for conditional return/break (base case)
595
+ if (/\bif\b.*\b(?:return|break)\b/.test(stripped) ||
596
+ /\b(?:return|break)\b.*\bif\b/.test(stripped)) {
597
+ foundBaseReturn = true;
598
+ }
599
+ // Guard clauses: `if (x) return;` at start of function
600
+ if (/^\s*if\s*\(.*\)\s*(?:return|break)\b/.test(stripped)) {
601
+ foundBaseReturn = true;
602
+ }
603
+ // Check for self-call
604
+ if (selfCallPat.test(stripped)) {
605
+ // If we found a base case before the recursive call, it's bounded
606
+ if (foundBaseReturn)
607
+ return true;
608
+ }
609
+ }
610
+ // Also check for depth/level parameters which imply bounded recursion
611
+ return false;
612
+ }
613
+ /**
614
+ * Check if a function has a depth/limit parameter (implies bounded recursion).
615
+ * Smarter than just checking for the word "depth" anywhere — checks the
616
+ * function signature specifically.
617
+ */
618
+ export function hasDepthParameter(funcLine) {
619
+ const paramMatch = funcLine.match(/\(([^)]*)\)/);
620
+ if (!paramMatch)
621
+ return false;
622
+ const params = paramMatch[1].toLowerCase();
623
+ return /\b(?:depth|level|max_depth|maxdepth|max_level|maxlevel|limit|max_retries|maxretries|remaining|budget)\b/.test(params);
624
+ }
625
+ // ═══════════════════════════════════════════════════════════════════
626
+ // I/O DETECTION — Check if code performs I/O (makes loops dangerous)
627
+ // ═══════════════════════════════════════════════════════════════════
628
+ /**
629
+ * Check if a code block contains I/O operations.
630
+ * Language-aware: knows which stdlib calls are I/O.
631
+ */
632
+ export function containsIO(body, lang) {
633
+ const patterns = getIOPatterns(lang);
634
+ const stripped = body.split('\n').map(l => stripStrings(l)).join('\n');
635
+ return patterns.some(pat => pat.test(stripped));
636
+ }
637
+ function getIOPatterns(lang) {
638
+ switch (lang) {
639
+ case 'js':
640
+ case 'ts':
641
+ return [
642
+ /\bfs\.\w+/, /\bfetch\s*\(/, /\baxios\.\w+/, /\bhttp\.\w+/,
643
+ /\.write\s*\(/, /\.send\s*\(/, /\bchild_process\./,
644
+ /\bprocess\.stdout/, /\bnet\.\w+/,
645
+ ];
646
+ case 'py':
647
+ return [
648
+ /\bopen\s*\(/, /\brequests\.\w+/, /\burllib\.\w+/,
649
+ /\bsubprocess\./, /\bos\.\w+/, /\bsocket\.\w+/,
650
+ /\.write\s*\(/, /\bprint\s*\(/,
651
+ ];
652
+ case 'go':
653
+ return [
654
+ /\bos\.\w+/, /\bnet\.\w+/, /\bhttp\.\w+/,
655
+ /\bio\.\w+/, /\bfmt\.Fprint/, /\bexec\.Command/,
656
+ ];
657
+ case 'java':
658
+ return [
659
+ /\bnew\s+File\w*\(/, /\bHttpClient\b/, /\bSocket\b/,
660
+ /\.write\s*\(/, /\bRuntime\.getRuntime\(\)/,
661
+ ];
662
+ case 'rs':
663
+ return [
664
+ /\bstd::fs::/, /\bstd::net::/, /\bstd::process::/,
665
+ /\.write\s*\(/, /\btokio::\w+/,
666
+ ];
667
+ case 'cs':
668
+ return [
669
+ /\bFile\.\w+/, /\bHttpClient\b/, /\bProcess\.Start/,
670
+ /\.Write\s*\(/, /\bSocket\b/,
671
+ ];
672
+ case 'rb':
673
+ return [
674
+ /\bFile\.\w+/, /\bNet::HTTP\b/, /\bIO\.\w+/,
675
+ /\.write\s*\(/, /\bsystem\s*\(/,
676
+ ];
677
+ default:
678
+ return [];
679
+ }
680
+ }
681
+ // ═══════════════════════════════════════════════════════════════════
682
+ // RETRY/CIRCUIT BREAKER — Smart limit detection
683
+ // ═══════════════════════════════════════════════════════════════════
684
+ /**
685
+ * Check if a loop body or its preamble contains a retry limit.
686
+ *
687
+ * Smart: checks variable declarations before the loop AND inside the loop.
688
+ * Recognizes both explicit counters and library patterns.
689
+ */
690
+ export function hasRetryLimit(lines, loopLine, bodyEnd) {
691
+ // Check preamble (10 lines before loop) for counter declarations
692
+ const preambleStart = Math.max(0, loopLine - 10);
693
+ const preamble = lines.slice(preambleStart, loopLine).join('\n');
694
+ // Check body for limit patterns
695
+ const body = lines.slice(loopLine, bodyEnd).join('\n');
696
+ const combined = preamble + '\n' + body;
697
+ return RETRY_LIMIT_PATTERNS.some(pat => pat.test(combined));
698
+ }
699
+ const RETRY_LIMIT_PATTERNS = [
700
+ /max.?retries?\s*[=:]/i,
701
+ /retry.?(?:count|limit)\s*[=:]/i,
702
+ /\battempt(?:s)?\s*[<>=!]+\s*\d+/i,
703
+ /\bretries?\s*[<>=!]+\s*\d+/i,
704
+ /\bcount\s*[<>=!]+\s*\d+.*\bbreak\b/i,
705
+ /\bMAX_/,
706
+ /\bbackoff\b/i,
707
+ /\bcircuit.?breaker\b/i,
708
+ /\bfor\s+\w+\s*(?::=|=|in\s+range)\s*\d+/, // for i = 0; i < N (bounded)
709
+ /\bfor\s+\w+\s+in\s+range\s*\(\s*\d+/, // Python: for i in range(N)
710
+ /\.retries?\s*\(\s*\d+\s*\)/, // library: .retry(3)
711
+ /\bretry\s*\(\s*\d+\s*\)/,
712
+ ];
713
+ /**
714
+ * Check if error handling inside a loop constitutes a retry pattern.
715
+ * Not just "catch exists" but "catch is followed by continue or the loop wraps the try".
716
+ */
717
+ export function hasCatchWithContinue(body, lang) {
718
+ if (lang === 'py') {
719
+ // Python: except ... followed by continue or pass
720
+ return /\bexcept\b[^:]*:[\s\S]*?\b(?:continue|pass)\b/.test(body);
721
+ }
722
+ if (lang === 'go') {
723
+ // Go: if err != nil { continue } or { log; continue }
724
+ return /\bif\s+err\s*!=\s*nil\b[\s\S]*?\bcontinue\b/.test(body);
725
+ }
726
+ if (lang === 'rb') {
727
+ return /\brescue\b[\s\S]*?\b(?:retry|next)\b/.test(body);
728
+ }
729
+ // JS/TS/Java/C#: catch { ... continue }
730
+ return /\bcatch\b[\s\S]*?\bcontinue\b/.test(body);
731
+ }
732
+ // ═══════════════════════════════════════════════════════════════════
733
+ // UTILITY
734
+ // ═══════════════════════════════════════════════════════════════════
735
+ function isFunctionBoundary(trimmed, lang) {
736
+ switch (lang) {
737
+ case 'py':
738
+ return /^(?:def|class|async\s+def)\s/.test(trimmed);
739
+ case 'go':
740
+ return /^func\s/.test(trimmed);
741
+ case 'rb':
742
+ return /^(?:def|class|module)\s/.test(trimmed);
743
+ case 'rs':
744
+ return /^(?:fn|impl|pub\s+fn|pub\s+async\s+fn)\s/.test(trimmed);
745
+ default:
746
+ return /^(?:export\s+)?(?:async\s+)?(?:function|class)\s/.test(trimmed);
747
+ }
748
+ }
749
+ /**
750
+ * Check if a line contains a process spawn call.
751
+ */
752
+ export function isProcessSpawn(line, lang) {
753
+ const stripped = stripStrings(line);
754
+ const patterns = getSpawnPatterns(lang);
755
+ for (const pat of patterns) {
756
+ const m = pat.exec(stripped);
757
+ if (m)
758
+ return m;
759
+ }
760
+ return null;
761
+ }
762
+ function getSpawnPatterns(lang) {
763
+ switch (lang) {
764
+ case 'js':
765
+ case 'ts':
766
+ return [
767
+ /\b(?:spawn|exec|execFile|fork|execa)\s*\(/,
768
+ /\bchild_process\.\w+\s*\(/,
769
+ ];
770
+ case 'py':
771
+ return [
772
+ /\bsubprocess\.(?:Popen|run|call|check_output|check_call)\s*\(/,
773
+ /\bos\.(?:system|exec\w*|spawn\w*)\s*\(/,
774
+ ];
775
+ case 'go':
776
+ return [/\bexec\.Command\s*\(/, /\bcmd\.(?:Start|Run)\s*\(/];
777
+ case 'java':
778
+ return [/\bProcessBuilder\b/, /\bRuntime\.getRuntime\(\)\.exec\s*\(/];
779
+ case 'rs':
780
+ return [/\bCommand::new\s*\(/];
781
+ case 'cs':
782
+ return [/\bProcess\.Start\s*\(/];
783
+ case 'rb':
784
+ return [/\bsystem\s*\(/, /\bIO\.popen\s*\(/, /\bspawn\s*\(/];
785
+ default:
786
+ return [];
787
+ }
788
+ }
789
+ /**
790
+ * Check if a line contains a timer creation call.
791
+ * Returns the timer function name if matched.
792
+ */
793
+ export function isTimerCreation(line, lang) {
794
+ const stripped = stripStrings(line);
795
+ const patterns = getTimerCreatePatterns(lang);
796
+ for (const [pat, name] of patterns) {
797
+ if (pat.test(stripped))
798
+ return name;
799
+ }
800
+ return null;
801
+ }
802
+ function getTimerCreatePatterns(lang) {
803
+ switch (lang) {
804
+ case 'js':
805
+ case 'ts':
806
+ return [
807
+ [/\bsetInterval\s*\(/, 'setInterval'],
808
+ [/\bsetTimeout\s*\(/, 'setTimeout'],
809
+ ];
810
+ case 'py':
811
+ return [
812
+ [/\bTimer\s*\(/, 'Timer'],
813
+ [/\bscheduler\.enter\s*\(/, 'scheduler.enter'],
814
+ [/\bschedule\.every\b/, 'schedule.every'],
815
+ ];
816
+ case 'go':
817
+ return [
818
+ [/\btime\.NewTicker\s*\(/, 'NewTicker'],
819
+ [/\btime\.Tick\s*\(/, 'Tick'],
820
+ ];
821
+ case 'java':
822
+ return [
823
+ [/\bScheduledExecutorService\b/, 'ScheduledExecutorService'],
824
+ [/\bTimer\(\)\.schedule/, 'Timer.schedule'],
825
+ ];
826
+ case 'cs':
827
+ return [
828
+ [/\bnew\s+Timer\s*\(/, 'Timer'],
829
+ ];
830
+ default:
831
+ return [];
832
+ }
833
+ }
834
+ /**
835
+ * Get cleanup patterns for timers (language-specific).
836
+ */
837
+ export function getTimerCleanupPatterns(lang) {
838
+ switch (lang) {
839
+ case 'js':
840
+ case 'ts':
841
+ return [/\bclearInterval\s*\(/, /\bclearTimeout\s*\(/];
842
+ case 'py':
843
+ return [/\.cancel\s*\(/];
844
+ case 'go':
845
+ return [/\.Stop\s*\(/];
846
+ case 'java':
847
+ return [/\.shutdown\s*\(/, /\.cancel\s*\(/];
848
+ case 'cs':
849
+ return [/\.Dispose\s*\(/, /\.Stop\s*\(/];
850
+ default:
851
+ return [];
852
+ }
853
+ }
854
+ /**
855
+ * Get cleanup patterns for spawned processes.
856
+ */
857
+ export function getProcessCleanupPatterns(lang) {
858
+ switch (lang) {
859
+ case 'js':
860
+ case 'ts':
861
+ return [
862
+ /\.on\s*\(\s*['"](?:exit|close)['"]/,
863
+ /\.kill\s*\(/,
864
+ /\.disconnect\s*\(/,
865
+ ];
866
+ case 'py':
867
+ return [/\.wait\s*\(/, /\.terminate\s*\(/, /\.kill\s*\(/, /\.communicate\s*\(/];
868
+ case 'go':
869
+ return [/\.Wait\s*\(/, /\.Process\.Kill\s*\(/];
870
+ case 'java':
871
+ return [/\.waitFor\s*\(/, /\.destroy\s*\(/];
872
+ case 'rs':
873
+ return [/\.wait\s*\(/, /\.kill\s*\(/];
874
+ case 'cs':
875
+ return [/\.WaitForExit\s*\(/, /\.Kill\s*\(/];
876
+ case 'rb':
877
+ return [/Process\.wait\b/, /Process\.kill\b/];
878
+ default:
879
+ return [];
880
+ }
881
+ }
882
+ /**
883
+ * Check if a line contains an unbounded loop construct.
884
+ */
885
+ export function isUnboundedLoop(line, lang) {
886
+ const stripped = stripStrings(line);
887
+ const patterns = getUnboundedLoopPatterns(lang);
888
+ return patterns.some(p => p.test(stripped));
889
+ }
890
+ function getUnboundedLoopPatterns(lang) {
891
+ switch (lang) {
892
+ case 'js':
893
+ case 'ts':
894
+ return [/\bwhile\s*\(\s*true\s*\)/, /\bwhile\s*\(\s*1\s*\)/, /\bfor\s*\(\s*;\s*;\s*\)/];
895
+ case 'py':
896
+ return [/\bwhile\s+True\s*:/, /\bwhile\s+1\s*:/];
897
+ case 'go':
898
+ return [/\bfor\s*\{/];
899
+ case 'java':
900
+ case 'cs':
901
+ return [/\bwhile\s*\(\s*true\s*\)/, /\bfor\s*\(\s*;\s*;\s*\)/];
902
+ case 'rs':
903
+ return [/\bloop\s*\{/];
904
+ case 'rb':
905
+ return [/\bloop\s+do\b/, /\bwhile\s+true\b/];
906
+ default:
907
+ return [];
908
+ }
909
+ }
910
+ /**
911
+ * Check if a line creates a file watcher.
912
+ * Returns the watcher function name if matched.
913
+ */
914
+ export function isFileWatcher(line, lang) {
915
+ const stripped = stripStrings(line);
916
+ const patterns = getWatcherPatterns(lang);
917
+ for (const [pat, name] of patterns) {
918
+ if (pat.test(stripped))
919
+ return name;
920
+ }
921
+ return null;
922
+ }
923
+ function getWatcherPatterns(lang) {
924
+ switch (lang) {
925
+ case 'js':
926
+ case 'ts':
927
+ return [
928
+ [/\bfs\.watch\s*\(/, 'fs.watch'],
929
+ [/\bfs\.watchFile\s*\(/, 'fs.watchFile'],
930
+ [/\bchokidar\.watch\s*\(/, 'chokidar.watch'],
931
+ [/\bnew\s+FSWatcher\b/, 'FSWatcher'],
932
+ ];
933
+ case 'py':
934
+ return [
935
+ [/\bObserver\s*\(/, 'Observer'],
936
+ [/\binotify\b/, 'inotify'],
937
+ [/\bwatchfiles\b/, 'watchfiles'],
938
+ ];
939
+ case 'go':
940
+ return [
941
+ [/\bfsnotify\.\w+/, 'fsnotify'],
942
+ [/\bNewWatcher\s*\(/, 'NewWatcher'],
943
+ ];
944
+ case 'java':
945
+ return [[/\bWatchService\b/, 'WatchService']];
946
+ case 'cs':
947
+ return [[/\bnew\s+FileSystemWatcher\b/, 'FileSystemWatcher']];
948
+ case 'rb':
949
+ return [[/\bListen\.\w+/, 'Listen']];
950
+ default:
951
+ return [];
952
+ }
953
+ }
954
+ /**
955
+ * Check if a code body contains file write operations.
956
+ * Returns the first write call found, or null.
957
+ */
958
+ export function findWriteInBody(body, lang) {
959
+ const patterns = getWritePatterns(lang);
960
+ for (const pat of patterns) {
961
+ const m = pat.exec(body);
962
+ if (m)
963
+ return m[0];
964
+ }
965
+ return null;
966
+ }
967
+ function getWritePatterns(lang) {
968
+ switch (lang) {
969
+ case 'js':
970
+ case 'ts':
971
+ return [/\bfs\.writeFile\w*/, /\bfs\.appendFile\w*/, /\bfs\.createWriteStream/];
972
+ case 'py':
973
+ return [/\bopen\s*\([^)]*['"][wa]['"]/, /\bshutil\.\w+/];
974
+ case 'go':
975
+ return [/\bos\.WriteFile/, /\bos\.Create/, /\bio\.WriteString/];
976
+ case 'java':
977
+ return [/\bFileWriter\b/, /\bBufferedWriter\b/];
978
+ case 'rs':
979
+ return [/\bfs::write/, /\bFile::create/];
980
+ case 'cs':
981
+ return [/\bFile\.Write\w*/, /\bStreamWriter\b/];
982
+ case 'rb':
983
+ return [/\bFile\.write/, /\bFile\.open\s*\([^)]*['"]w['"]/];
984
+ default:
985
+ return [];
986
+ }
987
+ }
988
+ /**
989
+ * Check if a file watcher callback has debounce/throttle protection.
990
+ */
991
+ export function hasDebounceProtection(body) {
992
+ return /\b(?:debounce|throttle|\.once\s*\(|ignore_self|ignoreInitial|_isProcessing|_skipNext|lock|mutex|semaphore)\b/i.test(body);
993
+ }
994
+ /**
995
+ * Get resource open patterns for lifecycle checking.
996
+ */
997
+ export function isResourceOpen(line, lang) {
998
+ const stripped = stripStrings(line);
999
+ const patterns = getResourceOpenPatterns(lang);
1000
+ for (const [pat, name] of patterns) {
1001
+ if (pat.test(stripped))
1002
+ return name;
1003
+ }
1004
+ return null;
1005
+ }
1006
+ function getResourceOpenPatterns(lang) {
1007
+ switch (lang) {
1008
+ case 'js':
1009
+ case 'ts':
1010
+ return [
1011
+ [/\bfs\.open\s*\(/, 'fs.open'],
1012
+ [/\bfs\.createReadStream\s*\(/, 'createReadStream'],
1013
+ [/\bfs\.createWriteStream\s*\(/, 'createWriteStream'],
1014
+ ];
1015
+ case 'py':
1016
+ return [[/\bopen\s*\(/, 'open']];
1017
+ case 'go':
1018
+ return [
1019
+ [/\bos\.Open\s*\(/, 'os.Open'],
1020
+ [/\bos\.Create\s*\(/, 'os.Create'],
1021
+ [/\bos\.OpenFile\s*\(/, 'os.OpenFile'],
1022
+ ];
1023
+ case 'java':
1024
+ return [
1025
+ [/\bnew\s+FileInputStream\b/, 'FileInputStream'],
1026
+ [/\bnew\s+FileOutputStream\b/, 'FileOutputStream'],
1027
+ [/\bnew\s+BufferedReader\b/, 'BufferedReader'],
1028
+ ];
1029
+ case 'rs':
1030
+ return [
1031
+ [/\bFile::open\s*\(/, 'File::open'],
1032
+ [/\bFile::create\s*\(/, 'File::create'],
1033
+ ];
1034
+ case 'cs':
1035
+ return [
1036
+ [/\bFile\.Open\s*\(/, 'File.Open'],
1037
+ [/\bnew\s+FileStream\b/, 'FileStream'],
1038
+ [/\bnew\s+StreamReader\b/, 'StreamReader'],
1039
+ ];
1040
+ case 'rb':
1041
+ return [[/\bFile\.open\s*\(/, 'File.open']];
1042
+ default:
1043
+ return [];
1044
+ }
1045
+ }
1046
+ /**
1047
+ * Get resource close patterns.
1048
+ */
1049
+ export function getResourceClosePatterns(lang) {
1050
+ switch (lang) {
1051
+ case 'js':
1052
+ case 'ts':
1053
+ return [/\.close\s*\(/, /\.destroy\s*\(/, /\.end\s*\(/];
1054
+ case 'py':
1055
+ return [/\.close\s*\(/];
1056
+ case 'go':
1057
+ return [/\.Close\s*\(/];
1058
+ case 'java':
1059
+ return [/\.close\s*\(/];
1060
+ case 'rs':
1061
+ return [/\bdrop\s*\(/];
1062
+ case 'cs':
1063
+ return [/\.Close\s*\(/, /\.Dispose\s*\(/];
1064
+ case 'rb':
1065
+ return [/\.close\b/];
1066
+ default:
1067
+ return [];
1068
+ }
1069
+ }
1070
+ /**
1071
+ * Check if an exit/signal handler respawns the process (auto-restart pattern).
1072
+ */
1073
+ export function isExitHandler(line, lang) {
1074
+ const stripped = stripStrings(line);
1075
+ switch (lang) {
1076
+ case 'js':
1077
+ case 'ts':
1078
+ return /process\.on\s*\(\s*['"](?:exit|uncaughtException|SIGTERM|SIGINT)['"]/.test(stripped);
1079
+ case 'py':
1080
+ return /\batexit\.register\s*\(/.test(stripped) ||
1081
+ /\bsignal\.signal\s*\(\s*signal\.SIG\w+/.test(stripped);
1082
+ case 'go':
1083
+ return /\bsignal\.Notify\s*\(/.test(stripped);
1084
+ case 'java':
1085
+ return /\bRuntime\.getRuntime\(\)\.addShutdownHook\b/.test(stripped);
1086
+ case 'cs':
1087
+ return /\bAppDomain\.CurrentDomain\.ProcessExit\b/.test(stripped);
1088
+ case 'rb':
1089
+ return /\bat_exit\b/.test(stripped) || /\btrap\s*\(/.test(stripped);
1090
+ case 'rs':
1091
+ return /\bctrlc::set_handler\b/.test(stripped) ||
1092
+ /\bsignal::ctrl_c\b/.test(stripped);
1093
+ default:
1094
+ return false;
1095
+ }
1096
+ }