python-vibe-guard 0.9.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.9.0 → python_vibe_guard-0.10.0}/PKG-INFO +1 -1
  2. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyproject.toml +1 -1
  3. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/PKG-INFO +1 -1
  4. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/SOURCES.txt +2 -0
  5. python_vibe_guard-0.10.0/pyvibe/__init__.py +1 -0
  6. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/analyzer.py +9 -2
  7. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/cli.py +113 -0
  8. python_vibe_guard-0.10.0/pyvibe/diff.py +121 -0
  9. python_vibe_guard-0.10.0/tests/test_diff.py +299 -0
  10. python_vibe_guard-0.9.0/pyvibe/__init__.py +0 -1
  11. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/README.md +0 -0
  12. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
  13. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
  14. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
  15. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/__main__.py +0 -0
  16. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/autofix.py +0 -0
  17. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/explain.py +0 -0
  18. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rule_docs.py +0 -0
  19. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/__init__.py +0 -0
  20. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/async_requests.py +0 -0
  21. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/async_sleep.py +0 -0
  22. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/asyncio_run.py +0 -0
  23. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/base.py +0 -0
  24. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/celery_time_limit.py +0 -0
  25. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
  26. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/create_task_orphan.py +0 -0
  27. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
  28. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
  29. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/httpx_client_sync.py +0 -0
  30. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/httpx_sync.py +0 -0
  31. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
  32. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/open_async.py +0 -0
  33. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/os_blocking.py +0 -0
  34. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/queue_put_nowait.py +0 -0
  35. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/retry_no_backoff.py +0 -0
  36. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/silent_except.py +0 -0
  37. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/sqlite_async.py +0 -0
  38. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/subprocess_async.py +0 -0
  39. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/threading_lock.py +0 -0
  40. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/while_true_no_await.py +0 -0
  41. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/sarif.py +0 -0
  42. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/setup.cfg +0 -0
  43. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/tests/test_exclude.py +0 -0
  44. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/tests/test_explain.py +0 -0
  45. {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/tests/test_rules.py +0 -0
  46. {python_vibe_guard-0.9.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.9.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-vibe-guard"
7
- version = "0.9.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.9.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
@@ -10,6 +10,7 @@ pyvibe/__main__.py
10
10
  pyvibe/analyzer.py
11
11
  pyvibe/autofix.py
12
12
  pyvibe/cli.py
13
+ pyvibe/diff.py
13
14
  pyvibe/explain.py
14
15
  pyvibe/rule_docs.py
15
16
  pyvibe/sarif.py
@@ -35,6 +36,7 @@ pyvibe/rules/sqlite_async.py
35
36
  pyvibe/rules/subprocess_async.py
36
37
  pyvibe/rules/threading_lock.py
37
38
  pyvibe/rules/while_true_no_await.py
39
+ tests/test_diff.py
38
40
  tests/test_exclude.py
39
41
  tests/test_explain.py
40
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
@@ -132,9 +132,16 @@ def analyze_file(
132
132
  path: Path,
133
133
  *,
134
134
  downgrade_in_tests: FrozenSet[str] = TEST_FILE_DOWNGRADE,
135
+ line_filter: Optional[FrozenSet[int]] = None,
135
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
+ """
136
140
  source = path.read_text(encoding="utf-8", errors="ignore")
137
- 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
138
145
 
139
146
 
140
147
  DEFAULT_EXCLUDES = frozenset({
@@ -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",
@@ -172,6 +178,113 @@ def _print_human(file_results: dict, total_violations: int, total_files: int):
172
178
  print()
173
179
 
174
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
+
175
288
  def _main_explain(argv):
176
289
  parser = argparse.ArgumentParser(
177
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)
@@ -0,0 +1,299 @@
1
+ """Tests for PR review mode (pyvibe/diff.py + `pyvibe review` CLI subcommand)."""
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import sys as _sys
8
+ import os
9
+
10
+ _sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ import pytest
13
+
14
+ from pyvibe.analyzer import analyze_file
15
+ from pyvibe.diff import GitDiffError, get_changed_python_files, parse_diff
16
+
17
+ REPO_ROOT = Path(__file__).resolve().parent.parent
18
+
19
+
20
+ # ─── parse_diff() unit tests ───────────────────────────────────────────────
21
+
22
+ def test_parse_diff_tracks_only_added_lines():
23
+ diff_text = """\
24
+ diff --git a/app.py b/app.py
25
+ index 1111111..2222222 100644
26
+ --- a/app.py
27
+ +++ b/app.py
28
+ @@ -1,3 +1,4 @@
29
+ import time
30
+
31
+ async def handler():
32
+ + time.sleep(1)
33
+ """
34
+ result = parse_diff(diff_text)
35
+ assert result == {"app.py": frozenset({4})}
36
+
37
+
38
+ def test_parse_diff_ignores_removed_lines():
39
+ diff_text = """\
40
+ diff --git a/app.py b/app.py
41
+ index 1111111..2222222 100644
42
+ --- a/app.py
43
+ +++ b/app.py
44
+ @@ -1,4 +1,3 @@
45
+ import time
46
+
47
+ async def handler():
48
+ - time.sleep(1)
49
+ """
50
+ result = parse_diff(diff_text)
51
+ assert result == {}
52
+
53
+
54
+ def test_parse_diff_advances_line_numbers_through_context():
55
+ diff_text = """\
56
+ diff --git a/app.py b/app.py
57
+ index 1111111..2222222 100644
58
+ --- a/app.py
59
+ +++ b/app.py
60
+ @@ -1,3 +1,5 @@
61
+ line one
62
+ line two
63
+ +added at three
64
+ line four
65
+ +added at five
66
+ """
67
+ result = parse_diff(diff_text)
68
+ assert result == {"app.py": frozenset({3, 5})}
69
+
70
+
71
+ def test_parse_diff_ignores_non_python_files():
72
+ diff_text = """\
73
+ diff --git a/README.md b/README.md
74
+ index 1111111..2222222 100644
75
+ --- a/README.md
76
+ +++ b/README.md
77
+ @@ -1,1 +1,2 @@
78
+ hello
79
+ +world
80
+ """
81
+ result = parse_diff(diff_text)
82
+ assert result == {}
83
+
84
+
85
+ def test_parse_diff_handles_multiple_files():
86
+ diff_text = """\
87
+ diff --git a/a.py b/a.py
88
+ index 1111111..2222222 100644
89
+ --- a/a.py
90
+ +++ b/a.py
91
+ @@ -1,1 +1,2 @@
92
+ x = 1
93
+ +y = 2
94
+ diff --git a/b.py b/b.py
95
+ index 3333333..4444444 100644
96
+ --- a/b.py
97
+ +++ b/b.py
98
+ @@ -1,1 +1,2 @@
99
+ z = 1
100
+ +w = 2
101
+ """
102
+ result = parse_diff(diff_text)
103
+ assert result == {"a.py": frozenset({2}), "b.py": frozenset({2})}
104
+
105
+
106
+ def test_parse_diff_new_file_reports_all_added_lines():
107
+ diff_text = """\
108
+ diff --git a/new.py b/new.py
109
+ new file mode 100644
110
+ index 0000000..1111111
111
+ --- /dev/null
112
+ +++ b/new.py
113
+ @@ -0,0 +1,2 @@
114
+ +import os
115
+ +print(os.getcwd())
116
+ """
117
+ result = parse_diff(diff_text)
118
+ assert result == {"new.py": frozenset({1, 2})}
119
+
120
+
121
+ def test_parse_diff_deleted_file_reports_nothing():
122
+ diff_text = """\
123
+ diff --git a/gone.py b/gone.py
124
+ deleted file mode 100644
125
+ index 1111111..0000000
126
+ --- a/gone.py
127
+ +++ /dev/null
128
+ @@ -1,2 +0,0 @@
129
+ -import os
130
+ -print(os.getcwd())
131
+ """
132
+ result = parse_diff(diff_text)
133
+ assert result == {}
134
+
135
+
136
+ def test_get_changed_python_files_rejects_flag_like_base():
137
+ with pytest.raises(GitDiffError, match="Invalid base ref"):
138
+ get_changed_python_files("--upload-pack=evil")
139
+
140
+
141
+ # ─── analyze_file(line_filter=...) unit tests ──────────────────────────────
142
+
143
+ VIOLATION_SRC = """\
144
+ import time
145
+
146
+ async def handler():
147
+ time.sleep(5)
148
+ """
149
+
150
+
151
+ def test_analyze_file_line_filter_keeps_matching_line(tmp_path):
152
+ src = tmp_path / "bad.py"
153
+ src.write_text(VIOLATION_SRC)
154
+
155
+ violations = analyze_file(src, line_filter=frozenset({4}))
156
+ assert len(violations) == 1
157
+ assert violations[0].line == 4
158
+
159
+
160
+ def test_analyze_file_line_filter_excludes_non_matching_line(tmp_path):
161
+ src = tmp_path / "bad.py"
162
+ src.write_text(VIOLATION_SRC)
163
+
164
+ violations = analyze_file(src, line_filter=frozenset({99}))
165
+ assert violations == []
166
+
167
+
168
+ def test_analyze_file_without_line_filter_returns_everything(tmp_path):
169
+ src = tmp_path / "bad.py"
170
+ src.write_text(VIOLATION_SRC)
171
+
172
+ violations = analyze_file(src)
173
+ assert len(violations) == 1
174
+
175
+
176
+ # ─── `pyvibe review` CLI (subprocess, end-to-end against a real git repo) ──
177
+
178
+ def _run_cli(args, cwd):
179
+ return subprocess.run(
180
+ [sys.executable, "-m", "pyvibe", *args],
181
+ cwd=cwd,
182
+ capture_output=True,
183
+ text=True,
184
+ )
185
+
186
+
187
+ def _git(args, cwd):
188
+ subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
189
+
190
+
191
+ def _init_repo(cwd):
192
+ _git(["init", "-q"], cwd)
193
+ _git(["config", "user.email", "test@test.com"], cwd)
194
+ _git(["config", "user.name", "Test"], cwd)
195
+
196
+
197
+ def test_cli_review_reports_only_added_violation(tmp_path):
198
+ _init_repo(tmp_path)
199
+ app = tmp_path / "app.py"
200
+ app.write_text("import time\n\ndef handler():\n print('ok')\n")
201
+ _git(["add", "app.py"], tmp_path)
202
+ _git(["commit", "-q", "-m", "initial"], tmp_path)
203
+
204
+ app.write_text(
205
+ "import time\n\ndef handler():\n print('ok')\n\n"
206
+ "async def process_order():\n time.sleep(2)\n"
207
+ )
208
+
209
+ result = _run_cli(["review", "--base", "HEAD"], cwd=tmp_path)
210
+
211
+ assert result.returncode == 1
212
+ assert "PYVIBE-001" in result.stdout
213
+ assert "line 7" in result.stdout
214
+
215
+
216
+ def test_cli_review_ignores_preexisting_violation_outside_diff(tmp_path):
217
+ _init_repo(tmp_path)
218
+ app = tmp_path / "app.py"
219
+ app.write_text(
220
+ "import time\n\nasync def handler():\n"
221
+ " time.sleep(1)\n print('existing violation, untouched')\n"
222
+ )
223
+ _git(["add", "app.py"], tmp_path)
224
+ _git(["commit", "-q", "-m", "baseline with pre-existing violation"], tmp_path)
225
+
226
+ with app.open("a") as f:
227
+ f.write("\ndef other():\n print('unrelated new function')\n")
228
+
229
+ full_scan = _run_cli(["app.py", "--json"], cwd=tmp_path)
230
+ assert len(json.loads(full_scan.stdout)) == 1
231
+
232
+ review = _run_cli(["review", "--base", "HEAD"], cwd=tmp_path)
233
+ assert review.returncode == 0
234
+ assert "No violations found" in review.stdout
235
+
236
+
237
+ def test_cli_review_clean_diff_exits_zero(tmp_path):
238
+ _init_repo(tmp_path)
239
+ app = tmp_path / "app.py"
240
+ app.write_text("def handler():\n pass\n")
241
+ _git(["add", "app.py"], tmp_path)
242
+ _git(["commit", "-q", "-m", "initial"], tmp_path)
243
+
244
+ app.write_text("def handler():\n pass\n\n\ndef other():\n pass\n")
245
+
246
+ result = _run_cli(["review", "--base", "HEAD"], cwd=tmp_path)
247
+ assert result.returncode == 0
248
+ assert "No violations found" in result.stdout
249
+
250
+
251
+ def test_cli_review_json_output(tmp_path):
252
+ _init_repo(tmp_path)
253
+ app = tmp_path / "app.py"
254
+ app.write_text("def handler():\n pass\n")
255
+ _git(["add", "app.py"], tmp_path)
256
+ _git(["commit", "-q", "-m", "initial"], tmp_path)
257
+
258
+ app.write_text(
259
+ "def handler():\n pass\n\nasync def process_order():\n import time\n time.sleep(2)\n"
260
+ )
261
+
262
+ result = _run_cli(["review", "--base", "HEAD", "--json"], cwd=tmp_path)
263
+ output = json.loads(result.stdout)
264
+ assert len(output) == 1
265
+ assert output[0]["rule"] == "PYVIBE-001"
266
+ assert output[0]["file"] == "app.py"
267
+
268
+
269
+ def test_cli_review_sarif_output(tmp_path):
270
+ _init_repo(tmp_path)
271
+ app = tmp_path / "app.py"
272
+ app.write_text("def handler():\n pass\n")
273
+ _git(["add", "app.py"], tmp_path)
274
+ _git(["commit", "-q", "-m", "initial"], tmp_path)
275
+
276
+ app.write_text(
277
+ "def handler():\n pass\n\nasync def process_order():\n import time\n time.sleep(2)\n"
278
+ )
279
+
280
+ out = tmp_path / "out.sarif"
281
+ result = _run_cli(
282
+ ["review", "--base", "HEAD", "--sarif", "--sarif-output", str(out)], cwd=tmp_path
283
+ )
284
+ assert result.returncode == 1
285
+ assert out.exists()
286
+ sarif = json.loads(out.read_text())
287
+ assert sarif["runs"][0]["results"][0]["ruleId"] == "PYVIBE-001"
288
+
289
+
290
+ def test_cli_review_invalid_base_exits_2(tmp_path):
291
+ _init_repo(tmp_path)
292
+ app = tmp_path / "app.py"
293
+ app.write_text("def handler():\n pass\n")
294
+ _git(["add", "app.py"], tmp_path)
295
+ _git(["commit", "-q", "-m", "initial"], tmp_path)
296
+
297
+ result = _run_cli(["review", "--base", "not-a-real-ref"], cwd=tmp_path)
298
+ assert result.returncode == 2
299
+ assert "Error" in result.stderr
@@ -1 +0,0 @@
1
- __version__ = "0.9.0"