python-vibe-guard 0.8.0__tar.gz → 0.10.0__tar.gz

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.
Files changed (46) hide show
  1. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/PKG-INFO +28 -5
  2. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/README.md +27 -4
  3. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyproject.toml +1 -1
  4. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/PKG-INFO +28 -5
  5. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/SOURCES.txt +3 -0
  6. python_vibe_guard-0.10.0/pyvibe/__init__.py +1 -0
  7. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/analyzer.py +22 -2
  8. python_vibe_guard-0.10.0/pyvibe/autofix.py +31 -0
  9. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/cli.py +118 -0
  10. python_vibe_guard-0.10.0/pyvibe/diff.py +121 -0
  11. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/async_requests.py +14 -2
  12. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/async_sleep.py +13 -1
  13. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/asyncio_run.py +15 -1
  14. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/base.py +1 -0
  15. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/sqlite_async.py +11 -2
  16. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/subprocess_async.py +43 -1
  17. python_vibe_guard-0.10.0/tests/test_diff.py +299 -0
  18. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/tests/test_rules.py +69 -0
  19. python_vibe_guard-0.8.0/pyvibe/__init__.py +0 -1
  20. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
  21. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
  22. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
  23. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/__main__.py +0 -0
  24. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/explain.py +0 -0
  25. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rule_docs.py +0 -0
  26. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/__init__.py +0 -0
  27. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/celery_time_limit.py +0 -0
  28. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
  29. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/create_task_orphan.py +0 -0
  30. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
  31. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
  32. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/httpx_client_sync.py +0 -0
  33. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/httpx_sync.py +0 -0
  34. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
  35. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/open_async.py +0 -0
  36. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/os_blocking.py +0 -0
  37. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/queue_put_nowait.py +0 -0
  38. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/retry_no_backoff.py +0 -0
  39. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/silent_except.py +0 -0
  40. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/threading_lock.py +0 -0
  41. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/while_true_no_await.py +0 -0
  42. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/sarif.py +0 -0
  43. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/setup.cfg +0 -0
  44. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/tests/test_exclude.py +0 -0
  45. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/tests/test_explain.py +0 -0
  46. {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/tests/test_sarif.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-vibe-guard
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong
5
5
  License: MIT
6
6
  Keywords: async,linter,fastapi,asyncio,static-analysis
@@ -184,22 +184,29 @@ python -m pyvibe explain PYVIBE-002
184
184
 
185
185
  demo/bad_async.py
186
186
 
187
- [CRITICAL] [PYVIBE-001] — line 14
187
+ [CRITICAL] [PYVIBE-001] — line 27
188
188
  Function : process_order()
189
189
  Problem : time.sleep() blocks the entire event loop
190
190
  Fix : Use `await asyncio.sleep(n)` instead
191
+ Suggested fix:
192
+ await asyncio.sleep(2)
191
193
 
192
- [CRITICAL] [PYVIBE-002] — line 20
194
+ [CRITICAL] [PYVIBE-002] — line 33
193
195
  Function : fetch_user()
194
196
  Problem : requests.get() is synchronous — blocks the event loop
195
197
  Fix : Use `async with httpx.AsyncClient() as c: await c.get(url)`
198
+ Suggested fix:
199
+ async with httpx.AsyncClient() as client:
200
+ response = await client.get(f"https://api.example.com/users/{user_id}")
196
201
 
197
- [CRITICAL] [PYVIBE-003] — line 26
202
+ [CRITICAL] [PYVIBE-003] — line 39
198
203
  Function : orchestrate()
199
204
  Problem : asyncio.run() inside async def raises RuntimeError at runtime
200
205
  Fix : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only
206
+ Suggested fix:
207
+ await process_order(42)
201
208
 
202
- [CRITICAL] [PYVIBE-004] — line 32
209
+ [CRITICAL] [PYVIBE-004] — line 45
203
210
  Function : update_counter()
204
211
  Problem : threading.Lock() blocks the event loop under contention
205
212
  Fix : Use `asyncio.Lock()` with `async with lock:` instead
@@ -208,6 +215,10 @@ python -m pyvibe explain PYVIBE-002
208
215
  4 violation(s) in 1 file(s)
209
216
  ```
210
217
 
218
+ `Suggested fix:` blocks are generated from the real code on the flagged line — they are
219
+ printed to the terminal / JSON output only and are never written back to your files.
220
+ Currently available for PYVIBE-001, 002, 003, 007, and 008.
221
+
211
222
  ### `pyvibe explain`
212
223
 
213
224
  Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
@@ -279,6 +290,18 @@ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `he
279
290
 
280
291
  ---
281
292
 
293
+ ## GitHub Action
294
+
295
+ - name: python-vibe-guard
296
+ uses: joaquinriosheredia/python-vibe-guard-action@v1
297
+ with:
298
+ path: src/
299
+
300
+ Violations appear directly in GitHub Security tab (SARIF).
301
+ Link: https://github.com/Joaquinriosheredia/python-vibe-guard-action
302
+
303
+ ---
304
+
282
305
  ## Pre-commit integration
283
306
 
284
307
  Add to your `.pre-commit-config.yaml`:
@@ -169,22 +169,29 @@ python -m pyvibe explain PYVIBE-002
169
169
 
170
170
  demo/bad_async.py
171
171
 
172
- [CRITICAL] [PYVIBE-001] — line 14
172
+ [CRITICAL] [PYVIBE-001] — line 27
173
173
  Function : process_order()
174
174
  Problem : time.sleep() blocks the entire event loop
175
175
  Fix : Use `await asyncio.sleep(n)` instead
176
+ Suggested fix:
177
+ await asyncio.sleep(2)
176
178
 
177
- [CRITICAL] [PYVIBE-002] — line 20
179
+ [CRITICAL] [PYVIBE-002] — line 33
178
180
  Function : fetch_user()
179
181
  Problem : requests.get() is synchronous — blocks the event loop
180
182
  Fix : Use `async with httpx.AsyncClient() as c: await c.get(url)`
183
+ Suggested fix:
184
+ async with httpx.AsyncClient() as client:
185
+ response = await client.get(f"https://api.example.com/users/{user_id}")
181
186
 
182
- [CRITICAL] [PYVIBE-003] — line 26
187
+ [CRITICAL] [PYVIBE-003] — line 39
183
188
  Function : orchestrate()
184
189
  Problem : asyncio.run() inside async def raises RuntimeError at runtime
185
190
  Fix : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only
191
+ Suggested fix:
192
+ await process_order(42)
186
193
 
187
- [CRITICAL] [PYVIBE-004] — line 32
194
+ [CRITICAL] [PYVIBE-004] — line 45
188
195
  Function : update_counter()
189
196
  Problem : threading.Lock() blocks the event loop under contention
190
197
  Fix : Use `asyncio.Lock()` with `async with lock:` instead
@@ -193,6 +200,10 @@ python -m pyvibe explain PYVIBE-002
193
200
  4 violation(s) in 1 file(s)
194
201
  ```
195
202
 
203
+ `Suggested fix:` blocks are generated from the real code on the flagged line — they are
204
+ printed to the terminal / JSON output only and are never written back to your files.
205
+ Currently available for PYVIBE-001, 002, 003, 007, and 008.
206
+
196
207
  ### `pyvibe explain`
197
208
 
198
209
  Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
@@ -264,6 +275,18 @@ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `he
264
275
 
265
276
  ---
266
277
 
278
+ ## GitHub Action
279
+
280
+ - name: python-vibe-guard
281
+ uses: joaquinriosheredia/python-vibe-guard-action@v1
282
+ with:
283
+ path: src/
284
+
285
+ Violations appear directly in GitHub Security tab (SARIF).
286
+ Link: https://github.com/Joaquinriosheredia/python-vibe-guard-action
287
+
288
+ ---
289
+
267
290
  ## Pre-commit integration
268
291
 
269
292
  Add to your `.pre-commit-config.yaml`:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-vibe-guard"
7
- version = "0.8.0"
7
+ version = "0.10.0"
8
8
  description = "Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-vibe-guard
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong
5
5
  License: MIT
6
6
  Keywords: async,linter,fastapi,asyncio,static-analysis
@@ -184,22 +184,29 @@ python -m pyvibe explain PYVIBE-002
184
184
 
185
185
  demo/bad_async.py
186
186
 
187
- [CRITICAL] [PYVIBE-001] — line 14
187
+ [CRITICAL] [PYVIBE-001] — line 27
188
188
  Function : process_order()
189
189
  Problem : time.sleep() blocks the entire event loop
190
190
  Fix : Use `await asyncio.sleep(n)` instead
191
+ Suggested fix:
192
+ await asyncio.sleep(2)
191
193
 
192
- [CRITICAL] [PYVIBE-002] — line 20
194
+ [CRITICAL] [PYVIBE-002] — line 33
193
195
  Function : fetch_user()
194
196
  Problem : requests.get() is synchronous — blocks the event loop
195
197
  Fix : Use `async with httpx.AsyncClient() as c: await c.get(url)`
198
+ Suggested fix:
199
+ async with httpx.AsyncClient() as client:
200
+ response = await client.get(f"https://api.example.com/users/{user_id}")
196
201
 
197
- [CRITICAL] [PYVIBE-003] — line 26
202
+ [CRITICAL] [PYVIBE-003] — line 39
198
203
  Function : orchestrate()
199
204
  Problem : asyncio.run() inside async def raises RuntimeError at runtime
200
205
  Fix : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only
206
+ Suggested fix:
207
+ await process_order(42)
201
208
 
202
- [CRITICAL] [PYVIBE-004] — line 32
209
+ [CRITICAL] [PYVIBE-004] — line 45
203
210
  Function : update_counter()
204
211
  Problem : threading.Lock() blocks the event loop under contention
205
212
  Fix : Use `asyncio.Lock()` with `async with lock:` instead
@@ -208,6 +215,10 @@ python -m pyvibe explain PYVIBE-002
208
215
  4 violation(s) in 1 file(s)
209
216
  ```
210
217
 
218
+ `Suggested fix:` blocks are generated from the real code on the flagged line — they are
219
+ printed to the terminal / JSON output only and are never written back to your files.
220
+ Currently available for PYVIBE-001, 002, 003, 007, and 008.
221
+
211
222
  ### `pyvibe explain`
212
223
 
213
224
  Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
@@ -279,6 +290,18 @@ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `he
279
290
 
280
291
  ---
281
292
 
293
+ ## GitHub Action
294
+
295
+ - name: python-vibe-guard
296
+ uses: joaquinriosheredia/python-vibe-guard-action@v1
297
+ with:
298
+ path: src/
299
+
300
+ Violations appear directly in GitHub Security tab (SARIF).
301
+ Link: https://github.com/Joaquinriosheredia/python-vibe-guard-action
302
+
303
+ ---
304
+
282
305
  ## Pre-commit integration
283
306
 
284
307
  Add to your `.pre-commit-config.yaml`:
@@ -8,7 +8,9 @@ python_vibe_guard.egg-info/top_level.txt
8
8
  pyvibe/__init__.py
9
9
  pyvibe/__main__.py
10
10
  pyvibe/analyzer.py
11
+ pyvibe/autofix.py
11
12
  pyvibe/cli.py
13
+ pyvibe/diff.py
12
14
  pyvibe/explain.py
13
15
  pyvibe/rule_docs.py
14
16
  pyvibe/sarif.py
@@ -34,6 +36,7 @@ pyvibe/rules/sqlite_async.py
34
36
  pyvibe/rules/subprocess_async.py
35
37
  pyvibe/rules/threading_lock.py
36
38
  pyvibe/rules/while_true_no_await.py
39
+ tests/test_diff.py
37
40
  tests/test_exclude.py
38
41
  tests/test_explain.py
39
42
  tests/test_rules.py
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
@@ -1,6 +1,6 @@
1
1
  import ast
2
2
  from pathlib import Path
3
- from typing import FrozenSet, List
3
+ from typing import FrozenSet, List, Optional
4
4
 
5
5
  from pyvibe.rules.base import Violation
6
6
  from pyvibe.rules.async_sleep import AsyncSleepRule
@@ -49,6 +49,17 @@ ALL_RULES = [
49
49
 
50
50
  ALL_RULE_IDS: FrozenSet[str] = frozenset(r.RULE_ID for r in ALL_RULES)
51
51
 
52
+ # Rules that build a concrete "Suggested fix" snippet from the real source
53
+ # text (see pyvibe/autofix.py) and therefore need the full source passed
54
+ # into their constructor.
55
+ _NEEDS_SOURCE: FrozenSet[type] = frozenset({
56
+ AsyncSleepRule,
57
+ AsyncRequestsRule,
58
+ AsyncioRunRule,
59
+ SubprocessAsyncRule,
60
+ SqliteAsyncRule,
61
+ })
62
+
52
63
  # Rules downgraded CRITICAL → WARNING when the violation is inside a test file.
53
64
  # time.sleep in fixtures, subprocess in service-startup helpers, open() in
54
65
  # async test helpers (e.g. asyncssh, aiofiles own test suites), and Celery task
@@ -101,6 +112,8 @@ def analyze_source(
101
112
  for RuleClass in ALL_RULES:
102
113
  if RuleClass is SilentExceptRule:
103
114
  visitor = RuleClass(source_lines)
115
+ elif RuleClass in _NEEDS_SOURCE:
116
+ visitor = RuleClass(source)
104
117
  else:
105
118
  visitor = RuleClass()
106
119
  visitor.visit(tree)
@@ -119,9 +132,16 @@ def analyze_file(
119
132
  path: Path,
120
133
  *,
121
134
  downgrade_in_tests: FrozenSet[str] = TEST_FILE_DOWNGRADE,
135
+ line_filter: Optional[FrozenSet[int]] = None,
122
136
  ) -> List[Violation]:
137
+ """line_filter: if given, only violations whose `line` is in this set
138
+ are returned (used by `pyvibe review` to report only diff-touched lines).
139
+ """
123
140
  source = path.read_text(encoding="utf-8", errors="ignore")
124
- return analyze_source(source, filepath=str(path), downgrade_in_tests=downgrade_in_tests)
141
+ violations = analyze_source(source, filepath=str(path), downgrade_in_tests=downgrade_in_tests)
142
+ if line_filter is not None:
143
+ violations = [v for v in violations if v.line in line_filter]
144
+ return violations
125
145
 
126
146
 
127
147
  DEFAULT_EXCLUDES = frozenset({
@@ -0,0 +1,31 @@
1
+ """Shared helpers for building concrete "Suggested fix" snippets from real source context.
2
+
3
+ Rules that support autofix suggestions receive the original source text and use
4
+ `ast.get_source_segment` to pull the exact expressions the developer wrote
5
+ (variable names, literals, kwargs) into the suggested replacement, instead of
6
+ a synthetic placeholder like `...`.
7
+ """
8
+ import ast
9
+ from typing import Optional
10
+
11
+
12
+ def render_node(source: str, node: ast.AST) -> Optional[str]:
13
+ """Return the literal source text of an AST node, or None if unavailable."""
14
+ return ast.get_source_segment(source, node)
15
+
16
+
17
+ def render_call_args(source: str, node: ast.Call) -> str:
18
+ """Reconstruct the literal argument-list text of a Call node from source."""
19
+ parts = []
20
+ for arg in node.args:
21
+ if isinstance(arg, ast.Starred):
22
+ seg = render_node(source, arg.value)
23
+ parts.append(f"*{seg}" if seg else "*args")
24
+ else:
25
+ seg = render_node(source, arg)
26
+ parts.append(seg if seg is not None else "...")
27
+ for kw in node.keywords:
28
+ seg = render_node(source, kw.value)
29
+ seg = seg if seg is not None else "..."
30
+ parts.append(f"**{seg}" if kw.arg is None else f"{kw.arg}={seg}")
31
+ return ", ".join(parts)
@@ -9,6 +9,8 @@ Usage:
9
9
  python -m pyvibe <path> --no-test-files # skip test files entirely
10
10
  python -m pyvibe <path> --downgrade-in-tests # WARNING instead of CRITICAL in all test files
11
11
  python -m pyvibe explain PYVIBE-002 # show research evidence for a rule
12
+ python -m pyvibe review # PR review: only diff-touched lines (git diff HEAD~1)
13
+ python -m pyvibe review --base main # diff against another ref
12
14
  """
13
15
  import argparse
14
16
  import json
@@ -31,6 +33,10 @@ def main():
31
33
  _main_explain(sys.argv[2:])
32
34
  return
33
35
 
36
+ if len(sys.argv) > 1 and sys.argv[1] == "review":
37
+ _main_review(sys.argv[2:])
38
+ return
39
+
34
40
  parser = argparse.ArgumentParser(
35
41
  prog="pyvibe",
36
42
  description="Detect runtime anti-patterns in async Python code",
@@ -132,6 +138,7 @@ def main():
132
138
  "function": v.function_name,
133
139
  "message": v.message,
134
140
  "evidence": v.evidence,
141
+ "suggested_fix": v.suggested_fix,
135
142
  })
136
143
  print(json.dumps(output, indent=2))
137
144
  else:
@@ -160,6 +167,10 @@ def _print_human(file_results: dict, total_violations: int, total_files: int):
160
167
  print(f" Function : {v.function_name}()")
161
168
  print(f" Problem : {v.message}")
162
169
  print(f" Fix : {v.evidence}")
170
+ if v.suggested_fix:
171
+ print(" Suggested fix:")
172
+ for line in v.suggested_fix.splitlines():
173
+ print(f" {line}")
163
174
  print()
164
175
 
165
176
  print(" ─────────────────────────────────────────────")
@@ -167,6 +178,113 @@ def _print_human(file_results: dict, total_violations: int, total_files: int):
167
178
  print()
168
179
 
169
180
 
181
+ _SEVERITY_ICON = {"CRITICAL": "❌", "WARNING": "⚠️"}
182
+
183
+
184
+ def _main_review(argv):
185
+ parser = argparse.ArgumentParser(
186
+ prog="pyvibe review",
187
+ description="PR review mode: analyze only lines added/modified in a git diff",
188
+ )
189
+ parser.add_argument(
190
+ "--base",
191
+ default="HEAD~1",
192
+ help="Git ref to diff against (default: HEAD~1)",
193
+ )
194
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
195
+ parser.add_argument(
196
+ "--sarif",
197
+ action="store_true",
198
+ help="Also write SARIF 2.1.0 output (for GitHub Code Scanning)",
199
+ )
200
+ parser.add_argument(
201
+ "--sarif-output",
202
+ metavar="PATH",
203
+ default="results.sarif",
204
+ help="Path to write SARIF output to (default: results.sarif)",
205
+ )
206
+ args = parser.parse_args(argv)
207
+
208
+ from pyvibe.diff import GitDiffError, get_changed_python_files, get_repo_root
209
+
210
+ try:
211
+ repo_root = get_repo_root()
212
+ changed = get_changed_python_files(args.base, cwd=repo_root)
213
+ except GitDiffError as e:
214
+ print(f"Error: {e}", file=sys.stderr)
215
+ sys.exit(2)
216
+
217
+ file_results = {}
218
+ for rel_path, added_lines in changed.items():
219
+ abs_path = repo_root / rel_path
220
+ if not abs_path.exists():
221
+ continue
222
+ violations = analyze_file(abs_path, line_filter=added_lines)
223
+ if violations:
224
+ file_results[rel_path] = violations
225
+
226
+ total_violations = sum(len(v) for v in file_results.values())
227
+
228
+ if args.sarif:
229
+ from pyvibe.sarif import write_sarif
230
+
231
+ write_sarif(file_results, args.sarif_output)
232
+ print(f"SARIF results written to {args.sarif_output}")
233
+
234
+ if args.json:
235
+ output = []
236
+ for path, violations in file_results.items():
237
+ for v in violations:
238
+ output.append({
239
+ "file": str(path),
240
+ "rule": v.rule_id,
241
+ "severity": v.severity,
242
+ "line": v.line,
243
+ "function": v.function_name,
244
+ "message": v.message,
245
+ "evidence": v.evidence,
246
+ "suggested_fix": v.suggested_fix,
247
+ })
248
+ print(json.dumps(output, indent=2))
249
+ else:
250
+ _print_review(file_results, changed, total_violations)
251
+
252
+ sys.exit(1 if total_violations > 0 else 0)
253
+
254
+
255
+ def _print_review(file_results: dict, changed: dict, total_violations: int):
256
+ print()
257
+ print(" 🐍 python-vibe-guard — PR Review")
258
+ print(" ─────────────────────────────────")
259
+ print(f" Analyzing {len(changed)} changed file(s)...")
260
+ print()
261
+
262
+ if not file_results:
263
+ print(" No violations found in new/modified code")
264
+ print()
265
+ return
266
+
267
+ for path, violations in file_results.items():
268
+ lines_reviewed = len(changed.get(path, frozenset()))
269
+ print(f" 📄 {path} (+{lines_reviewed} lines reviewed)")
270
+ print()
271
+ for v in violations:
272
+ icon = _SEVERITY_ICON.get(v.severity, "•")
273
+ print(f" {icon} {v.severity} [{v.rule_id}] — line {v.line} (NEW)")
274
+ print(f" Function : {v.function_name}()")
275
+ print(f" Problem : {v.message}")
276
+ print(f" Fix : {v.evidence}")
277
+ if v.suggested_fix:
278
+ print(" Suggested fix:")
279
+ for line in v.suggested_fix.splitlines():
280
+ print(f" {line}")
281
+ print()
282
+
283
+ print(" ─────────────────────────────────")
284
+ print(f" {total_violations} violation(s) in new/modified code")
285
+ print()
286
+
287
+
170
288
  def _main_explain(argv):
171
289
  parser = argparse.ArgumentParser(
172
290
  prog="pyvibe explain",
@@ -0,0 +1,121 @@
1
+ """Diff parsing for `pyvibe review` — extracts changed .py files and the
2
+ new-file line numbers touched by a `git diff`, so review only reports
3
+ violations on lines that are actually part of the diff (deleted lines and
4
+ untouched context are never reported).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import Dict, FrozenSet, Optional
12
+
13
+ _PLUS_PLUS_RE = re.compile(r"^\+\+\+ (?:b/(?P<path>.+)|(?P<devnull>/dev/null))$")
14
+ _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P<new_start>\d+)(?:,\d+)? @@")
15
+
16
+
17
+ class GitDiffError(Exception):
18
+ """Raised when `git diff`/`git rev-parse` can't be run or the base ref is invalid."""
19
+
20
+
21
+ def get_repo_root(cwd: Optional[Path] = None) -> Path:
22
+ """Resolve the top-level directory of the current git repository.
23
+
24
+ Needed because `git diff` always reports paths relative to the repo
25
+ root, not the caller's cwd — file paths from the diff must be resolved
26
+ against this root, not against Path.cwd().
27
+ """
28
+ result = subprocess.run(
29
+ ["git", "rev-parse", "--show-toplevel"],
30
+ cwd=cwd,
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+ if result.returncode != 0:
35
+ raise GitDiffError("Not inside a git repository")
36
+ return Path(result.stdout.strip())
37
+
38
+
39
+ def get_git_diff(base: str, cwd: Optional[Path] = None) -> str:
40
+ """Run `git diff <base> -- '*.py'` and return its raw text output.
41
+
42
+ Only ever invoked with a list-form subprocess call (no shell=True), so
43
+ `base` can't break out into arbitrary shell commands. It's still
44
+ rejected up front if it looks like a flag, since git itself would
45
+ otherwise interpret e.g. "--upload-pack=..." as an option rather than
46
+ a ref.
47
+ """
48
+ if base.startswith("-"):
49
+ raise GitDiffError(f"Invalid base ref: {base!r}")
50
+
51
+ try:
52
+ result = subprocess.run(
53
+ ["git", "diff", base, "--", "*.py"],
54
+ cwd=cwd,
55
+ capture_output=True,
56
+ text=True,
57
+ )
58
+ except FileNotFoundError as e:
59
+ raise GitDiffError("git executable not found") from e
60
+
61
+ if result.returncode != 0:
62
+ raise GitDiffError(result.stderr.strip() or f"git diff failed for base {base!r}")
63
+
64
+ return result.stdout
65
+
66
+
67
+ def parse_diff(diff_text: str) -> Dict[str, FrozenSet[int]]:
68
+ """Parse unified `git diff` output into {file_path: {added_line_numbers}}.
69
+
70
+ Only .py files are kept. Deleted lines (prefix "-") and unchanged
71
+ context lines never appear in the result — only lines that are actually
72
+ added/modified in the new version of the file.
73
+ """
74
+ files: Dict[str, set] = {}
75
+ current_file: Optional[str] = None
76
+ current_line: Optional[int] = None
77
+
78
+ for line in diff_text.splitlines():
79
+ if line.startswith("diff --git "):
80
+ current_file = None
81
+ current_line = None
82
+ continue
83
+
84
+ if line.startswith("+++ "):
85
+ m = _PLUS_PLUS_RE.match(line)
86
+ current_file = m.group("path") if (m and m.group("path")) else None
87
+ current_line = None
88
+ continue
89
+
90
+ if current_file is None or not current_file.endswith(".py"):
91
+ continue
92
+
93
+ hunk_match = _HUNK_RE.match(line)
94
+ if hunk_match:
95
+ current_line = int(hunk_match.group("new_start"))
96
+ continue
97
+
98
+ if current_line is None:
99
+ continue
100
+
101
+ if line.startswith("\\"):
102
+ continue # ""
103
+ elif line.startswith("+"):
104
+ files.setdefault(current_file, set()).add(current_line)
105
+ current_line += 1
106
+ elif line.startswith("-"):
107
+ continue # removed line — doesn't exist in the new file
108
+ else:
109
+ current_line += 1 # unchanged context line
110
+
111
+ return {path: frozenset(lines) for path, lines in files.items()}
112
+
113
+
114
+ def get_changed_python_files(base: str, cwd: Optional[Path] = None) -> Dict[str, FrozenSet[int]]:
115
+ """High-level entry point used by `pyvibe review`.
116
+
117
+ Returns {repo-relative path: {added line numbers}} for every .py file
118
+ touched in `git diff <base>`.
119
+ """
120
+ diff_text = get_git_diff(base, cwd=cwd)
121
+ return parse_diff(diff_text)
@@ -1,5 +1,6 @@
1
1
  import ast
2
- from typing import List, Set
2
+ from typing import List, Optional, Set
3
+ from pyvibe.autofix import render_call_args
3
4
  from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
5
 
5
6
  REQUESTS_METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "request"}
@@ -19,8 +20,9 @@ class AsyncRequestsRule(AsyncBlockingCallVisitor):
19
20
  RULE_ID = "PYVIBE-002"
20
21
  SEVERITY = "CRITICAL"
21
22
 
22
- def __init__(self):
23
+ def __init__(self, source: Optional[str] = None):
23
24
  super().__init__()
25
+ self._source = source
24
26
  # Names bound to the `requests` module via `import requests [as X]`
25
27
  self._requests_aliases: Set[str] = set()
26
28
 
@@ -44,10 +46,20 @@ class AsyncRequestsRule(AsyncBlockingCallVisitor):
44
46
  function_name=self._current_async_func,
45
47
  message=f"requests.{method}() is synchronous — blocks the event loop",
46
48
  evidence=f"Use `async with httpx.AsyncClient() as c: await c.{method}(url)`",
49
+ suggested_fix=self._suggest_fix(node, method),
47
50
  ))
48
51
 
49
52
  self.generic_visit(node)
50
53
 
54
+ def _suggest_fix(self, node: ast.Call, method: str) -> Optional[str]:
55
+ if not self._source:
56
+ return None
57
+ args_src = render_call_args(self._source, node)
58
+ return (
59
+ "async with httpx.AsyncClient() as client:\n"
60
+ f" response = await client.{method}({args_src})"
61
+ )
62
+
51
63
  def _get_requests_method(self, node: ast.Call):
52
64
  # requests.get(...), requests.post(...), etc. — qualified form only.
53
65
  # Guard against NAME_COLLISION: only flag when the receiver name is
@@ -1,5 +1,6 @@
1
1
  import ast
2
- from typing import List
2
+ from typing import List, Optional
3
+ from pyvibe.autofix import render_call_args
3
4
  from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
5
 
5
6
 
@@ -17,6 +18,10 @@ class AsyncSleepRule(AsyncBlockingCallVisitor):
17
18
  RULE_ID = "PYVIBE-001"
18
19
  SEVERITY = "CRITICAL"
19
20
 
21
+ def __init__(self, source: Optional[str] = None):
22
+ super().__init__()
23
+ self._source = source
24
+
20
25
  def visit_Call(self, node: ast.Call):
21
26
  if self._current_async_func is None:
22
27
  self.generic_visit(node)
@@ -30,10 +35,17 @@ class AsyncSleepRule(AsyncBlockingCallVisitor):
30
35
  function_name=self._current_async_func,
31
36
  message="time.sleep() blocks the entire event loop",
32
37
  evidence="Use `await asyncio.sleep(n)` instead",
38
+ suggested_fix=self._suggest_fix(node),
33
39
  ))
34
40
 
35
41
  self.generic_visit(node)
36
42
 
43
+ def _suggest_fix(self, node: ast.Call) -> Optional[str]:
44
+ if not self._source:
45
+ return None
46
+ args_src = render_call_args(self._source, node)
47
+ return f"await asyncio.sleep({args_src})"
48
+
37
49
  def _is_time_sleep(self, node: ast.Call) -> bool:
38
50
  # time.sleep(n) — qualified form only; bare sleep() excluded (false positives)
39
51
  return (