@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.
- package/dist/gates/deep-analysis.js +8 -1
- package/dist/gates/runner.js +19 -1
- package/dist/gates/side-effect-analysis.d.ts +67 -0
- package/dist/gates/side-effect-analysis.js +559 -0
- package/dist/gates/side-effect-helpers.d.ts +260 -0
- package/dist/gates/side-effect-helpers.js +1096 -0
- package/dist/gates/side-effect-rules.d.ts +39 -0
- package/dist/gates/side-effect-rules.js +302 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/inference/model-manager.d.ts +3 -3
- package/dist/inference/model-manager.js +109 -77
- package/dist/inference/types.d.ts +11 -0
- package/dist/inference/types.js +30 -2
- package/dist/storage/db.d.ts +19 -0
- package/dist/storage/db.js +88 -1
- package/dist/storage/findings.d.ts +2 -1
- package/dist/storage/findings.js +3 -2
- package/dist/storage/index.d.ts +4 -2
- package/dist/storage/index.js +2 -1
- package/dist/storage/local-memory.d.ts +37 -0
- package/dist/storage/local-memory.js +153 -0
- package/dist/templates/universal-config.js +12 -0
- package/dist/types/index.d.ts +140 -0
- package/dist/types/index.js +14 -0
- package/package.json +6 -6
|
@@ -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
|
+
};
|