repomind-local 0.3.2__py3-none-any.whl

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.
repomind/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RepoMind investigates a repository on your machine and proves a patch with tests."""
2
+
3
+ __version__ = "0.3.2"
@@ -0,0 +1 @@
1
+ """Static analysis used by the specialist agents."""
@@ -0,0 +1,482 @@
1
+ """Focused checks for a Python backend. Every finding cites a line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from repomind.analysis.detectors import _enclosing_function, _finding, _line
8
+
9
+ _HTTP = {"get", "post", "put", "patch", "delete", "head", "request", "urlopen"}
10
+ _ROUTE = {"get", "post", "put", "patch", "delete", "head", "options", "api_route"}
11
+ _BLOCKING = {
12
+ "time.sleep",
13
+ "requests.get",
14
+ "requests.post",
15
+ "requests.put",
16
+ "requests.patch",
17
+ "requests.delete",
18
+ "requests.request",
19
+ "urllib.request.urlopen",
20
+ "subprocess.run",
21
+ "subprocess.call",
22
+ "subprocess.check_call",
23
+ "subprocess.check_output",
24
+ }
25
+ _QUERY = {"execute", "executemany", "executescript", "query", "find_one"}
26
+ _SECRET_NAMES = {"password", "passwd", "secret", "api_key", "apikey", "access_token", "private_key"}
27
+
28
+
29
+ def _dotted(node: ast.AST) -> str:
30
+ if isinstance(node, ast.Name):
31
+ return node.id
32
+ if isinstance(node, ast.Attribute):
33
+ parent = _dotted(node.value)
34
+ return f"{parent}.{node.attr}" if parent else node.attr
35
+ return ""
36
+
37
+
38
+ def _has_keyword(node: ast.Call, name: str) -> bool:
39
+ return any(keyword.arg == name for keyword in node.keywords)
40
+
41
+
42
+ def _route_decorator(node: ast.AST) -> ast.Call | None:
43
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
44
+ return None
45
+ for decorator in node.decorator_list:
46
+ call = decorator if isinstance(decorator, ast.Call) else None
47
+ if call is None:
48
+ continue
49
+ name = _dotted(call.func)
50
+ if name.split(".")[-1] in _ROUTE:
51
+ return call
52
+ return None
53
+
54
+
55
+ def _in_loop(tree: ast.AST, target: ast.AST) -> bool:
56
+ for node in ast.walk(tree):
57
+ if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
58
+ for child in ast.walk(node):
59
+ if child is target:
60
+ return True
61
+ return False
62
+
63
+
64
+ def _retry_decorator(node: ast.AST) -> bool:
65
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
66
+ return False
67
+ for decorator in node.decorator_list:
68
+ name = _dotted(decorator.func if isinstance(decorator, ast.Call) else decorator)
69
+ if "retry" in name.lower():
70
+ return True
71
+ return False
72
+
73
+
74
+ def extra_checks(tree: ast.AST, source: str, rel: str) -> list[dict]:
75
+ found: list[dict] = []
76
+ found.extend(_http_calls(tree, source, rel))
77
+ found.extend(_blocking_and_routes(tree, source, rel))
78
+ found.extend(_dependencies(tree, source, rel))
79
+ found.extend(_exceptions(tree, source, rel))
80
+ found.extend(_weak_auth(tree, source, rel))
81
+ found.extend(_sql(tree, source, rel))
82
+ found.extend(_secrets(tree, source, rel))
83
+ found.extend(_shell(tree, source, rel))
84
+ found.extend(_resources(tree, source, rel))
85
+ found.extend(_query_loops(tree, source, rel))
86
+ return found
87
+
88
+
89
+ def _http_calls(tree: ast.AST, source: str, rel: str) -> list[dict]:
90
+ found: list[dict] = []
91
+ retries = 0
92
+ for node in ast.walk(tree):
93
+ if not isinstance(node, ast.Call):
94
+ continue
95
+ name = _dotted(node.func)
96
+ short = name.split(".")[-1]
97
+ if short not in _HTTP or not any(prefix in name for prefix in ("requests.", "httpx.", "urllib.")):
98
+ continue
99
+ function = _enclosing_function(tree, node)
100
+ owner = _owner_function(tree, node)
101
+ if not _has_keyword(node, "timeout"):
102
+ found.append(
103
+ _finding(
104
+ detector="missing_timeout",
105
+ category="latency",
106
+ confidence=0.74,
107
+ file=rel,
108
+ line=node.lineno,
109
+ title=f"`{name}` has no timeout",
110
+ detail=f"`{function or 'this call'}` can wait forever if the remote service stalls.",
111
+ evidence=_line(source, node.lineno),
112
+ function=function,
113
+ extra={"call": name},
114
+ )
115
+ )
116
+ if retries < 8 and owner is not None and not _retry_decorator(owner) and not _in_loop(tree, node):
117
+ retries += 1
118
+ found.append(
119
+ _finding(
120
+ detector="missing_retry",
121
+ category="defect",
122
+ confidence=0.46,
123
+ file=rel,
124
+ line=node.lineno,
125
+ title=f"`{name}` is not retried",
126
+ detail="The external call is not inside a retry loop or a retry decorator.",
127
+ evidence=_line(source, node.lineno),
128
+ function=function,
129
+ extra={"call": name},
130
+ )
131
+ )
132
+ return found
133
+
134
+
135
+ def _owner_function(tree: ast.AST, target: ast.AST) -> ast.AST | None:
136
+ for node in ast.walk(tree):
137
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
138
+ for child in ast.walk(node):
139
+ if child is target:
140
+ return node
141
+ return None
142
+
143
+
144
+ def _is_fastapi(source: str) -> bool:
145
+ lowered = source.lower()
146
+ return "fastapi" in lowered or "apirouter" in lowered
147
+
148
+
149
+ def _blocking_and_routes(tree: ast.AST, source: str, rel: str) -> list[dict]:
150
+ found: list[dict] = []
151
+ missing_models = 0
152
+ fastapi = _is_fastapi(source)
153
+ for node in ast.walk(tree):
154
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
155
+ continue
156
+ route = _route_decorator(node)
157
+ if (
158
+ fastapi
159
+ and route is not None
160
+ and missing_models < 8
161
+ and not _has_keyword(route, "response_model")
162
+ ):
163
+ missing_models += 1
164
+ found.append(
165
+ _finding(
166
+ detector="missing_response_model",
167
+ category="fastapi",
168
+ confidence=0.5,
169
+ file=rel,
170
+ line=route.lineno,
171
+ title=f"`{node.name}` has no response_model",
172
+ detail="The route does not declare the shape of its response.",
173
+ evidence=_line(source, route.lineno),
174
+ function=node.name,
175
+ )
176
+ )
177
+ if not isinstance(node, ast.AsyncFunctionDef):
178
+ continue
179
+ for child in ast.walk(node):
180
+ if not isinstance(child, ast.Call) or child is route:
181
+ continue
182
+ called = _dotted(child.func)
183
+ if called not in _BLOCKING and not called.startswith("requests."):
184
+ continue
185
+ routed = fastapi and route is not None
186
+ titled = "fastapi_blocking_route" if routed else "blocking_in_async"
187
+ category = "fastapi" if routed else "latency"
188
+ found.append(
189
+ _finding(
190
+ detector=titled,
191
+ category=category,
192
+ confidence=0.8,
193
+ file=rel,
194
+ line=child.lineno,
195
+ title=f"Async `{node.name}` calls blocking `{called}`",
196
+ detail="This call blocks the event loop. An async route should await a non-blocking client, or use asyncio.to_thread for a deliberate blocking boundary.",
197
+ evidence=_line(source, child.lineno),
198
+ function=node.name,
199
+ extra={"call": called},
200
+ )
201
+ )
202
+ return found
203
+
204
+
205
+ def _dependencies(tree: ast.AST, source: str, rel: str) -> list[dict]:
206
+ found: list[dict] = []
207
+ for node in ast.walk(tree):
208
+ if not _is_fastapi(source):
209
+ return found
210
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or _route_decorator(node) is None:
211
+ continue
212
+ for default in node.args.defaults:
213
+ if not isinstance(default, ast.Call):
214
+ continue
215
+ name = _dotted(default.func)
216
+ if name.split(".")[-1] == "Depends":
217
+ if default.args and isinstance(default.args[0], ast.Call):
218
+ found.append(
219
+ _finding(
220
+ detector="called_dependency",
221
+ category="fastapi",
222
+ confidence=0.84,
223
+ file=rel,
224
+ line=default.lineno,
225
+ title=f"`{node.name}` calls the dependency before Depends",
226
+ detail="Depends should receive the callable, not the result of calling it.",
227
+ evidence=_line(source, default.lineno),
228
+ function=node.name,
229
+ )
230
+ )
231
+ continue
232
+ if name.startswith("get_"):
233
+ found.append(
234
+ _finding(
235
+ detector="missing_depends",
236
+ category="fastapi",
237
+ confidence=0.8,
238
+ file=rel,
239
+ line=default.lineno,
240
+ title=f"`{node.name}` calls `{name}` without Depends",
241
+ detail="FastAPI only injects the value when the default is wrapped in Depends.",
242
+ evidence=_line(source, default.lineno),
243
+ function=node.name,
244
+ )
245
+ )
246
+ return found
247
+
248
+
249
+ def _exceptions(tree: ast.AST, source: str, rel: str) -> list[dict]:
250
+ found: list[dict] = []
251
+ for node in ast.walk(tree):
252
+ if not isinstance(node, ast.ExceptHandler):
253
+ continue
254
+ bare = node.type is None
255
+ broad = isinstance(node.type, ast.Name) and node.type.id in {"Exception", "BaseException"}
256
+ if not bare and not broad:
257
+ continue
258
+ swallowed = len(node.body) == 1 and isinstance(node.body[0], ast.Pass)
259
+ if not bare and not swallowed:
260
+ continue
261
+ function = _enclosing_function(tree, node)
262
+ title = "Exception is swallowed" if swallowed else "Bare except hides the failure"
263
+ found.append(
264
+ _finding(
265
+ detector="swallowed_except",
266
+ category="defect",
267
+ confidence=0.76,
268
+ file=rel,
269
+ line=node.lineno,
270
+ title=title,
271
+ detail=f"`{function or 'this handler'}` catches a broad failure and does not name it.",
272
+ evidence=_line(source, node.lineno),
273
+ function=function,
274
+ extra={"bare": bare},
275
+ )
276
+ )
277
+ return found
278
+
279
+
280
+ def _weak_auth(tree: ast.AST, source: str, rel: str) -> list[dict]:
281
+ found: list[dict] = []
282
+ for node in ast.walk(tree):
283
+ if not isinstance(node, ast.Call):
284
+ continue
285
+ name = _dotted(node.func)
286
+ if "jwt" not in name.lower() and not name.endswith("decode"):
287
+ continue
288
+ function = _enclosing_function(tree, node)
289
+ for keyword in node.keywords:
290
+ if keyword.arg == "verify" and _is_false(keyword.value):
291
+ found.append(_auth_finding(rel, source, node, function, "JWT signature verification is disabled"))
292
+ if keyword.arg == "algorithms" and _contains_none(keyword.value):
293
+ found.append(_auth_finding(rel, source, node, function, "JWT allows the none algorithm"))
294
+ if keyword.arg == "options" and isinstance(keyword.value, ast.Dict):
295
+ for key, value in zip(keyword.value.keys, keyword.value.values):
296
+ if isinstance(key, ast.Constant) and key.value == "verify_signature" and _is_false(value):
297
+ found.append(_auth_finding(rel, source, node, function, "JWT signature verification is disabled"))
298
+ return found
299
+
300
+
301
+ def _auth_finding(rel: str, source: str, node: ast.Call, function: str | None, title: str) -> dict:
302
+ return _finding(
303
+ detector="weak_jwt",
304
+ category="auth",
305
+ confidence=0.86,
306
+ file=rel,
307
+ line=node.lineno,
308
+ title=title,
309
+ detail=f"`{function or 'this decode'}` accepts a token without checking its signature.",
310
+ evidence=_line(source, node.lineno),
311
+ function=function,
312
+ )
313
+
314
+
315
+ def _is_false(node: ast.AST) -> bool:
316
+ return isinstance(node, ast.Constant) and node.value is False
317
+
318
+
319
+ def _contains_none(node: ast.AST) -> bool:
320
+ if not isinstance(node, (ast.List, ast.Tuple, ast.Set)):
321
+ return False
322
+ return any(isinstance(item, ast.Constant) and item.value == "none" for item in node.elts)
323
+
324
+
325
+ def _sql(tree: ast.AST, source: str, rel: str) -> list[dict]:
326
+ found: list[dict] = []
327
+ for node in ast.walk(tree):
328
+ if not isinstance(node, ast.Call) or not node.args:
329
+ continue
330
+ if _dotted(node.func).split(".")[-1] not in {"execute", "executemany", "executescript"}:
331
+ continue
332
+ query = node.args[0]
333
+ interpolated = isinstance(query, ast.JoinedStr) or (
334
+ isinstance(query, ast.BinOp) and isinstance(query.op, (ast.Mod, ast.Add))
335
+ )
336
+ formatted = isinstance(query, ast.Call) and _dotted(query.func).endswith(".format")
337
+ if not interpolated and not formatted:
338
+ continue
339
+ function = _enclosing_function(tree, node)
340
+ found.append(
341
+ _finding(
342
+ detector="sql_interpolation",
343
+ category="security",
344
+ confidence=0.82,
345
+ file=rel,
346
+ line=node.lineno,
347
+ title="SQL is built with string interpolation",
348
+ detail=f"`{function or 'this query'}` puts data into the SQL string instead of using parameters.",
349
+ evidence=_line(source, node.lineno),
350
+ function=function,
351
+ )
352
+ )
353
+ return found
354
+
355
+
356
+ def _secrets(tree: ast.AST, source: str, rel: str) -> list[dict]:
357
+ found: list[dict] = []
358
+ for node in ast.walk(tree):
359
+ if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Constant):
360
+ continue
361
+ if not isinstance(node.value.value, str) or len(node.value.value) < 6:
362
+ continue
363
+ for target in node.targets:
364
+ if isinstance(target, ast.Name) and target.id.lower() in _SECRET_NAMES:
365
+ found.append(
366
+ _finding(
367
+ detector="hardcoded_secret",
368
+ category="security",
369
+ confidence=0.7,
370
+ file=rel,
371
+ line=node.lineno,
372
+ title=f"`{target.id}` is hardcoded",
373
+ detail="The secret is stored in source. Move it to an environment variable.",
374
+ evidence=_line(source, node.lineno),
375
+ function=_enclosing_function(tree, node),
376
+ )
377
+ )
378
+ return found
379
+
380
+
381
+ def _shell(tree: ast.AST, source: str, rel: str) -> list[dict]:
382
+ found: list[dict] = []
383
+ for node in ast.walk(tree):
384
+ if not isinstance(node, ast.Call):
385
+ continue
386
+ if _dotted(node.func).split(".")[-1] not in {"run", "call", "check_call", "check_output", "Popen"}:
387
+ continue
388
+ if not any(keyword.arg == "shell" and _is_true(keyword.value) for keyword in node.keywords):
389
+ continue
390
+ found.append(
391
+ _finding(
392
+ detector="shell_true",
393
+ category="security",
394
+ confidence=0.8,
395
+ file=rel,
396
+ line=node.lineno,
397
+ title="Subprocess runs through the shell",
398
+ detail="shell=True lets metacharacters in the command string change what runs.",
399
+ evidence=_line(source, node.lineno),
400
+ function=_enclosing_function(tree, node),
401
+ )
402
+ )
403
+ return found
404
+
405
+
406
+ def _is_true(node: ast.AST) -> bool:
407
+ return isinstance(node, ast.Constant) and node.value is True
408
+
409
+
410
+ def _resources(tree: ast.AST, source: str, rel: str) -> list[dict]:
411
+ managed: set[int] = set()
412
+ for node in ast.walk(tree):
413
+ if isinstance(node, (ast.With, ast.AsyncWith)):
414
+ for item in node.items:
415
+ managed.add(id(item.context_expr))
416
+ closed: set[str] = set()
417
+ for node in ast.walk(tree):
418
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "close":
419
+ if isinstance(node.func.value, ast.Name):
420
+ closed.add(node.func.value.id)
421
+ found: list[dict] = []
422
+ for node in ast.walk(tree):
423
+ if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call):
424
+ continue
425
+ if id(node.value) in managed or not _is_resource(node.value):
426
+ continue
427
+ if not node.targets or not isinstance(node.targets[0], ast.Name):
428
+ continue
429
+ name = node.targets[0].id
430
+ if name in closed:
431
+ continue
432
+ kind = _dotted(node.value.func) or "open"
433
+ found.append(
434
+ _finding(
435
+ detector="resource_leak",
436
+ category="defect",
437
+ confidence=0.73,
438
+ file=rel,
439
+ line=node.lineno,
440
+ title=f"`{name}` is opened and never closed",
441
+ detail=f"`{kind}` is not used as a context manager and `{name}.close()` is not called.",
442
+ evidence=_line(source, node.lineno),
443
+ function=_enclosing_function(tree, node),
444
+ extra={"call": kind},
445
+ )
446
+ )
447
+ return found
448
+
449
+
450
+ def _is_resource(node: ast.Call) -> bool:
451
+ name = _dotted(node.func)
452
+ return name == "open" or name.endswith(".open") or name.endswith("Session") or name.endswith(".Session")
453
+
454
+
455
+ def _query_loops(tree: ast.AST, source: str, rel: str) -> list[dict]:
456
+ found: list[dict] = []
457
+ for node in ast.walk(tree):
458
+ if not isinstance(node, (ast.For, ast.AsyncFor)):
459
+ continue
460
+ for child in ast.walk(node):
461
+ if child is node or not isinstance(child, ast.Call):
462
+ continue
463
+ short = _dotted(child.func).split(".")[-1]
464
+ if short not in _QUERY:
465
+ continue
466
+ function = _enclosing_function(tree, node)
467
+ found.append(
468
+ _finding(
469
+ detector="query_in_loop",
470
+ category="latency",
471
+ confidence=0.64,
472
+ file=rel,
473
+ line=child.lineno,
474
+ title=f"`{short}` runs inside a loop",
475
+ detail=f"`{function or 'this loop'}` can issue one query per item.",
476
+ evidence=_line(source, child.lineno),
477
+ function=function,
478
+ extra={"call": short},
479
+ )
480
+ )
481
+ break
482
+ return found