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.
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/PKG-INFO +1 -1
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyproject.toml +1 -1
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/PKG-INFO +1 -1
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/SOURCES.txt +2 -0
- python_vibe_guard-0.10.0/pyvibe/__init__.py +1 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/analyzer.py +9 -2
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/cli.py +113 -0
- python_vibe_guard-0.10.0/pyvibe/diff.py +121 -0
- python_vibe_guard-0.10.0/tests/test_diff.py +299 -0
- python_vibe_guard-0.9.0/pyvibe/__init__.py +0 -1
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/README.md +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/__main__.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/autofix.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/explain.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rule_docs.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/__init__.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/async_requests.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/async_sleep.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/asyncio_run.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/base.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/celery_time_limit.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/create_task_orphan.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/httpx_client_sync.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/httpx_sync.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/open_async.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/os_blocking.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/queue_put_nowait.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/retry_no_backoff.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/silent_except.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/sqlite_async.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/subprocess_async.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/threading_lock.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/while_true_no_await.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/sarif.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/setup.cfg +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/tests/test_exclude.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/tests/test_explain.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/tests/test_rules.py +0 -0
- {python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/tests/test_sarif.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-vibe-guard"
|
|
7
|
-
version = "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"
|
|
@@ -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
|
-
|
|
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"
|
|
File without changes
|
{python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/gather_no_return_exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_vibe_guard-0.9.0 → python_vibe_guard-0.10.0}/pyvibe/rules/loop_run_until_complete.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|