python-vibe-guard 0.10.0__tar.gz → 0.11.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 (48) hide show
  1. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/PKG-INFO +38 -2
  2. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/README.md +37 -1
  3. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyproject.toml +1 -1
  4. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/python_vibe_guard.egg-info/PKG-INFO +38 -2
  5. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/python_vibe_guard.egg-info/SOURCES.txt +2 -0
  6. python_vibe_guard-0.11.0/pyvibe/__init__.py +1 -0
  7. python_vibe_guard-0.11.0/pyvibe/baseline.py +73 -0
  8. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/cli.py +190 -16
  9. python_vibe_guard-0.11.0/tests/test_baseline.py +234 -0
  10. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/tests/test_diff.py +5 -0
  11. python_vibe_guard-0.10.0/pyvibe/__init__.py +0 -1
  12. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
  13. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
  14. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
  15. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/__main__.py +0 -0
  16. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/analyzer.py +0 -0
  17. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/autofix.py +0 -0
  18. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/diff.py +0 -0
  19. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/explain.py +0 -0
  20. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rule_docs.py +0 -0
  21. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/__init__.py +0 -0
  22. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/async_requests.py +0 -0
  23. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/async_sleep.py +0 -0
  24. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/asyncio_run.py +0 -0
  25. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/base.py +0 -0
  26. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/celery_time_limit.py +0 -0
  27. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
  28. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/create_task_orphan.py +0 -0
  29. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
  30. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
  31. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/httpx_client_sync.py +0 -0
  32. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/httpx_sync.py +0 -0
  33. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
  34. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/open_async.py +0 -0
  35. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/os_blocking.py +0 -0
  36. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/queue_put_nowait.py +0 -0
  37. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/retry_no_backoff.py +0 -0
  38. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/silent_except.py +0 -0
  39. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/sqlite_async.py +0 -0
  40. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/subprocess_async.py +0 -0
  41. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/threading_lock.py +0 -0
  42. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/rules/while_true_no_await.py +0 -0
  43. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/pyvibe/sarif.py +0 -0
  44. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/setup.cfg +0 -0
  45. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/tests/test_exclude.py +0 -0
  46. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/tests/test_explain.py +0 -0
  47. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.0}/tests/test_rules.py +0 -0
  48. {python_vibe_guard-0.10.0 → python_vibe_guard-0.11.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.10.0
3
+ Version: 0.11.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
@@ -173,6 +173,10 @@ python -m pyvibe src/ --exclude tests
173
173
  # Show the research evidence behind a rule (accuracy, false positives, sources)
174
174
  python -m pyvibe explain PYVIBE-002
175
175
 
176
+ # Baseline mode: suppress pre-existing findings, only fail on new ones
177
+ python -m pyvibe baseline create src/ # snapshot current findings
178
+ python -m pyvibe src/ --baseline # only reports findings NOT in the baseline
179
+
176
180
  # Exit code: 0 = clean, 1 = violations found, 2 = path error
177
181
  ```
178
182
 
@@ -255,6 +259,38 @@ python -m pyvibe explain PYVIBE-002
255
259
  If a rule has no evidence file, it prints a clear `No evidence file found for PYVIBE-XXX`
256
260
  and exits non-zero — it never invents data.
257
261
 
262
+ ### Baseline mode
263
+
264
+ Adopting python-vibe-guard on an existing codebase usually means hundreds of pre-existing
265
+ findings you can't fix before CI needs to go green. Baseline mode snapshots the current
266
+ findings once, then only fails on genuinely **new** ones:
267
+
268
+ ```bash
269
+ # Snapshot every current finding into .pyvibe-baseline.json
270
+ python -m pyvibe baseline create src/
271
+
272
+ # Refuses to run if a baseline already exists — use `update` to overwrite it
273
+ python -m pyvibe baseline update src/
274
+
275
+ # Full scan, but only reports findings NOT already in the baseline
276
+ python -m pyvibe src/ --baseline
277
+ python -m pyvibe src/ --baseline --json
278
+ python -m pyvibe src/ --baseline --sarif
279
+
280
+ # `pyvibe scan` is an equivalent, explicit subcommand form of the same flags
281
+ python -m pyvibe scan src/ --baseline
282
+ ```
283
+
284
+ A finding is considered "already known" on an exact match of `(file path, rule ID, line
285
+ number)`. Anything that doesn't match — a new violation, or an existing one that moved to
286
+ a different line — is reported and fails the scan (exit code `1`); an unchanged baseline
287
+ produces exit code `0`.
288
+
289
+ `.pyvibe-baseline.json` is a plain JSON file — whether to commit it or add it to
290
+ `.gitignore` is up to your team. Commit it if you want the accepted-debt snapshot shared
291
+ and reviewed like any other file; gitignore it if each contributor/CI run should
292
+ regenerate its own baseline instead.
293
+
258
294
  ---
259
295
 
260
296
  ## CI/CD integration
@@ -352,7 +388,7 @@ python -m pytest tests/ -v
352
388
  python tests/test_rules.py
353
389
  ```
354
390
 
355
- 226 tests: true positives + false-positive guards for every rule, plus SARIF output and `pyvibe explain` coverage.
391
+ 268 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, and baseline mode coverage.
356
392
 
357
393
  ---
358
394
 
@@ -158,6 +158,10 @@ python -m pyvibe src/ --exclude tests
158
158
  # Show the research evidence behind a rule (accuracy, false positives, sources)
159
159
  python -m pyvibe explain PYVIBE-002
160
160
 
161
+ # Baseline mode: suppress pre-existing findings, only fail on new ones
162
+ python -m pyvibe baseline create src/ # snapshot current findings
163
+ python -m pyvibe src/ --baseline # only reports findings NOT in the baseline
164
+
161
165
  # Exit code: 0 = clean, 1 = violations found, 2 = path error
162
166
  ```
163
167
 
@@ -240,6 +244,38 @@ python -m pyvibe explain PYVIBE-002
240
244
  If a rule has no evidence file, it prints a clear `No evidence file found for PYVIBE-XXX`
241
245
  and exits non-zero — it never invents data.
242
246
 
247
+ ### Baseline mode
248
+
249
+ Adopting python-vibe-guard on an existing codebase usually means hundreds of pre-existing
250
+ findings you can't fix before CI needs to go green. Baseline mode snapshots the current
251
+ findings once, then only fails on genuinely **new** ones:
252
+
253
+ ```bash
254
+ # Snapshot every current finding into .pyvibe-baseline.json
255
+ python -m pyvibe baseline create src/
256
+
257
+ # Refuses to run if a baseline already exists — use `update` to overwrite it
258
+ python -m pyvibe baseline update src/
259
+
260
+ # Full scan, but only reports findings NOT already in the baseline
261
+ python -m pyvibe src/ --baseline
262
+ python -m pyvibe src/ --baseline --json
263
+ python -m pyvibe src/ --baseline --sarif
264
+
265
+ # `pyvibe scan` is an equivalent, explicit subcommand form of the same flags
266
+ python -m pyvibe scan src/ --baseline
267
+ ```
268
+
269
+ A finding is considered "already known" on an exact match of `(file path, rule ID, line
270
+ number)`. Anything that doesn't match — a new violation, or an existing one that moved to
271
+ a different line — is reported and fails the scan (exit code `1`); an unchanged baseline
272
+ produces exit code `0`.
273
+
274
+ `.pyvibe-baseline.json` is a plain JSON file — whether to commit it or add it to
275
+ `.gitignore` is up to your team. Commit it if you want the accepted-debt snapshot shared
276
+ and reviewed like any other file; gitignore it if each contributor/CI run should
277
+ regenerate its own baseline instead.
278
+
243
279
  ---
244
280
 
245
281
  ## CI/CD integration
@@ -337,7 +373,7 @@ python -m pytest tests/ -v
337
373
  python tests/test_rules.py
338
374
  ```
339
375
 
340
- 226 tests: true positives + false-positive guards for every rule, plus SARIF output and `pyvibe explain` coverage.
376
+ 268 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, and baseline mode coverage.
341
377
 
342
378
  ---
343
379
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-vibe-guard"
7
- version = "0.10.0"
7
+ version = "0.11.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.10.0
3
+ Version: 0.11.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
@@ -173,6 +173,10 @@ python -m pyvibe src/ --exclude tests
173
173
  # Show the research evidence behind a rule (accuracy, false positives, sources)
174
174
  python -m pyvibe explain PYVIBE-002
175
175
 
176
+ # Baseline mode: suppress pre-existing findings, only fail on new ones
177
+ python -m pyvibe baseline create src/ # snapshot current findings
178
+ python -m pyvibe src/ --baseline # only reports findings NOT in the baseline
179
+
176
180
  # Exit code: 0 = clean, 1 = violations found, 2 = path error
177
181
  ```
178
182
 
@@ -255,6 +259,38 @@ python -m pyvibe explain PYVIBE-002
255
259
  If a rule has no evidence file, it prints a clear `No evidence file found for PYVIBE-XXX`
256
260
  and exits non-zero — it never invents data.
257
261
 
262
+ ### Baseline mode
263
+
264
+ Adopting python-vibe-guard on an existing codebase usually means hundreds of pre-existing
265
+ findings you can't fix before CI needs to go green. Baseline mode snapshots the current
266
+ findings once, then only fails on genuinely **new** ones:
267
+
268
+ ```bash
269
+ # Snapshot every current finding into .pyvibe-baseline.json
270
+ python -m pyvibe baseline create src/
271
+
272
+ # Refuses to run if a baseline already exists — use `update` to overwrite it
273
+ python -m pyvibe baseline update src/
274
+
275
+ # Full scan, but only reports findings NOT already in the baseline
276
+ python -m pyvibe src/ --baseline
277
+ python -m pyvibe src/ --baseline --json
278
+ python -m pyvibe src/ --baseline --sarif
279
+
280
+ # `pyvibe scan` is an equivalent, explicit subcommand form of the same flags
281
+ python -m pyvibe scan src/ --baseline
282
+ ```
283
+
284
+ A finding is considered "already known" on an exact match of `(file path, rule ID, line
285
+ number)`. Anything that doesn't match — a new violation, or an existing one that moved to
286
+ a different line — is reported and fails the scan (exit code `1`); an unchanged baseline
287
+ produces exit code `0`.
288
+
289
+ `.pyvibe-baseline.json` is a plain JSON file — whether to commit it or add it to
290
+ `.gitignore` is up to your team. Commit it if you want the accepted-debt snapshot shared
291
+ and reviewed like any other file; gitignore it if each contributor/CI run should
292
+ regenerate its own baseline instead.
293
+
258
294
  ---
259
295
 
260
296
  ## CI/CD integration
@@ -352,7 +388,7 @@ python -m pytest tests/ -v
352
388
  python tests/test_rules.py
353
389
  ```
354
390
 
355
- 226 tests: true positives + false-positive guards for every rule, plus SARIF output and `pyvibe explain` coverage.
391
+ 268 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, and baseline mode coverage.
356
392
 
357
393
  ---
358
394
 
@@ -9,6 +9,7 @@ pyvibe/__init__.py
9
9
  pyvibe/__main__.py
10
10
  pyvibe/analyzer.py
11
11
  pyvibe/autofix.py
12
+ pyvibe/baseline.py
12
13
  pyvibe/cli.py
13
14
  pyvibe/diff.py
14
15
  pyvibe/explain.py
@@ -36,6 +37,7 @@ pyvibe/rules/sqlite_async.py
36
37
  pyvibe/rules/subprocess_async.py
37
38
  pyvibe/rules/threading_lock.py
38
39
  pyvibe/rules/while_true_no_await.py
40
+ tests/test_baseline.py
39
41
  tests/test_diff.py
40
42
  tests/test_exclude.py
41
43
  tests/test_explain.py
@@ -0,0 +1 @@
1
+ __version__ = "0.11.0"
@@ -0,0 +1,73 @@
1
+ """Baseline support — `pyvibe baseline create/update` and `pyvibe scan --baseline`.
2
+
3
+ A baseline is a snapshot of existing findings ({filepath: [{rule_id, line,
4
+ message}, ...]}) that a team can choose to commit or gitignore (see README).
5
+ Once created, `pyvibe scan --baseline` only reports genuinely NEW
6
+ violations — anything already in the baseline is suppressed — so a
7
+ codebase can adopt python-vibe-guard incrementally instead of having to
8
+ fix every existing hit before CI goes green.
9
+ """
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Dict, Set, Tuple
13
+
14
+ DEFAULT_BASELINE_PATH = ".pyvibe-baseline.json"
15
+
16
+
17
+ class BaselineNotFoundError(Exception):
18
+ """Raised when `pyvibe scan --baseline` can't find/parse the baseline file."""
19
+
20
+
21
+ def build_baseline(file_results: Dict) -> dict:
22
+ """file_results: {path: [Violation, ...]} as produced by analyze_file /
23
+ analyze_directory. Returns the JSON-serializable baseline:
24
+ {filepath: [{rule_id, line, message}, ...]}.
25
+ """
26
+ return {
27
+ str(path): [
28
+ {"rule_id": v.rule_id, "line": v.line, "message": v.message}
29
+ for v in violations
30
+ ]
31
+ for path, violations in file_results.items()
32
+ }
33
+
34
+
35
+ def write_baseline(file_results: Dict, output_path=DEFAULT_BASELINE_PATH) -> None:
36
+ baseline = build_baseline(file_results)
37
+ Path(output_path).write_text(json.dumps(baseline, indent=2), encoding="utf-8")
38
+
39
+
40
+ def load_baseline(path=DEFAULT_BASELINE_PATH) -> dict:
41
+ baseline_path = Path(path)
42
+ if not baseline_path.exists():
43
+ raise BaselineNotFoundError(
44
+ f"No baseline found at {path}. Run `pyvibe baseline create` first."
45
+ )
46
+ try:
47
+ return json.loads(baseline_path.read_text(encoding="utf-8"))
48
+ except json.JSONDecodeError as e:
49
+ raise BaselineNotFoundError(f"Baseline at {path} is not valid JSON: {e}") from e
50
+
51
+
52
+ def _baseline_keys(baseline: dict) -> Set[Tuple[str, str, int]]:
53
+ """Flatten the baseline into (filepath, rule_id, line) triples for exact,
54
+ O(1) membership checks.
55
+ """
56
+ return {
57
+ (filepath, entry["rule_id"], entry["line"])
58
+ for filepath, entries in baseline.items()
59
+ for entry in entries
60
+ }
61
+
62
+
63
+ def filter_new_violations(file_results: Dict, baseline: dict) -> Dict:
64
+ """Return only the violations in file_results NOT present in baseline,
65
+ matched exactly on (filepath, rule_id, line).
66
+ """
67
+ known = _baseline_keys(baseline)
68
+ new_results = {}
69
+ for path, violations in file_results.items():
70
+ new = [v for v in violations if (str(path), v.rule_id, v.line) not in known]
71
+ if new:
72
+ new_results[path] = new
73
+ return new_results
@@ -11,6 +11,10 @@ Usage:
11
11
  python -m pyvibe explain PYVIBE-002 # show research evidence for a rule
12
12
  python -m pyvibe review # PR review: only diff-touched lines (git diff HEAD~1)
13
13
  python -m pyvibe review --base main # diff against another ref
14
+ python -m pyvibe baseline create [path] # snapshot existing findings to .pyvibe-baseline.json
15
+ python -m pyvibe baseline update [path] # overwrite the existing baseline
16
+ python -m pyvibe <path> --baseline # scan, suppressing findings already in the baseline
17
+ python -m pyvibe scan <path> --baseline # equivalent, explicit subcommand form
14
18
  """
15
19
  import argparse
16
20
  import json
@@ -26,6 +30,7 @@ from pyvibe.analyzer import (
26
30
  TEST_FILE_DOWNGRADE,
27
31
  _is_test_file,
28
32
  )
33
+ from pyvibe.baseline import DEFAULT_BASELINE_PATH
29
34
 
30
35
 
31
36
  def main():
@@ -37,6 +42,14 @@ def main():
37
42
  _main_review(sys.argv[2:])
38
43
  return
39
44
 
45
+ if len(sys.argv) > 1 and sys.argv[1] == "baseline":
46
+ _main_baseline(sys.argv[2:])
47
+ return
48
+
49
+ if len(sys.argv) > 1 and sys.argv[1] == "scan":
50
+ _main_scan(sys.argv[2:])
51
+ return
52
+
40
53
  parser = argparse.ArgumentParser(
41
54
  prog="pyvibe",
42
55
  description="Detect runtime anti-patterns in async Python code",
@@ -82,8 +95,34 @@ def main():
82
95
  "Default: only PYVIBE-001, PYVIBE-007, and PYVIBE-013 are downgraded."
83
96
  ),
84
97
  )
98
+ parser.add_argument(
99
+ "--baseline",
100
+ action="store_true",
101
+ help="Only report findings not already present in the baseline (see `pyvibe baseline create`)",
102
+ )
103
+ parser.add_argument(
104
+ "--baseline-path",
105
+ metavar="PATH",
106
+ default=DEFAULT_BASELINE_PATH,
107
+ help=f"Path to the baseline file (default: {DEFAULT_BASELINE_PATH})",
108
+ )
85
109
  args = parser.parse_args()
86
110
 
111
+ file_results = _resolve_file_results(args)
112
+ file_results = _apply_baseline_filter(args, file_results)
113
+ total_violations = sum(len(v) for v in file_results.values())
114
+ total_files = sum(1 for v in file_results.values() if v)
115
+
116
+ _emit_scan_output(args, file_results, total_violations, total_files)
117
+
118
+ sys.exit(1 if total_violations > 0 else 0)
119
+
120
+
121
+ def _resolve_file_results(args) -> dict:
122
+ """Shared by the bare `pyvibe <path>` command and `pyvibe scan`.
123
+
124
+ Expects args.path/.exclude/.no_test_files/.downgrade_in_tests.
125
+ """
87
126
  target = Path(args.path)
88
127
  if not target.exists():
89
128
  print(f"Error: {target} does not exist", file=sys.stderr)
@@ -104,22 +143,42 @@ def main():
104
143
 
105
144
  if target.is_file():
106
145
  if target.suffix != ".py":
107
- file_results = {}
108
- elif skip_test_files and _is_test_file(str(target)):
109
- file_results = {}
110
- else:
111
- file_results = {target: analyze_file(target, downgrade_in_tests=downgrade_in_tests)}
112
- else:
113
- file_results = analyze_directory(
114
- target,
115
- exclude=exclude,
116
- skip_test_files=skip_test_files,
117
- downgrade_in_tests=downgrade_in_tests,
118
- )
146
+ return {}
147
+ if skip_test_files and _is_test_file(str(target)):
148
+ return {}
149
+ return {target: analyze_file(target, downgrade_in_tests=downgrade_in_tests)}
150
+
151
+ return analyze_directory(
152
+ target,
153
+ exclude=exclude,
154
+ skip_test_files=skip_test_files,
155
+ downgrade_in_tests=downgrade_in_tests,
156
+ )
119
157
 
120
- total_violations = sum(len(v) for v in file_results.values())
121
- total_files = sum(1 for v in file_results.values() if v)
122
158
 
159
+ def _apply_baseline_filter(args, file_results: dict) -> dict:
160
+ """Shared by the bare `pyvibe <path>` command and `pyvibe scan`.
161
+
162
+ Expects args.baseline/.baseline_path. No-op when --baseline wasn't passed.
163
+ """
164
+ if not args.baseline:
165
+ return file_results
166
+
167
+ from pyvibe.baseline import BaselineNotFoundError, filter_new_violations, load_baseline
168
+
169
+ try:
170
+ baseline = load_baseline(args.baseline_path)
171
+ except BaselineNotFoundError as e:
172
+ print(f"Error: {e}", file=sys.stderr)
173
+ sys.exit(2)
174
+ return filter_new_violations(file_results, baseline)
175
+
176
+
177
+ def _emit_scan_output(args, file_results: dict, total_violations: int, total_files: int):
178
+ """Shared by the bare `pyvibe <path>` command and `pyvibe scan`.
179
+
180
+ Expects args.sarif/.sarif_output/.json.
181
+ """
123
182
  if args.sarif:
124
183
  from pyvibe.sarif import write_sarif
125
184
 
@@ -144,8 +203,6 @@ def main():
144
203
  else:
145
204
  _print_human(file_results, total_violations, total_files)
146
205
 
147
- sys.exit(1 if total_violations > 0 else 0)
148
-
149
206
 
150
207
  def _print_human(file_results: dict, total_violations: int, total_files: int):
151
208
  print()
@@ -285,6 +342,123 @@ def _print_review(file_results: dict, changed: dict, total_violations: int):
285
342
  print()
286
343
 
287
344
 
345
+ def _main_scan(argv):
346
+ parser = argparse.ArgumentParser(
347
+ prog="pyvibe scan",
348
+ description="Scan for anti-patterns (same as bare `pyvibe <path>`), with optional baseline filtering",
349
+ )
350
+ parser.add_argument("path", help="File or directory to scan")
351
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
352
+ parser.add_argument(
353
+ "--sarif",
354
+ action="store_true",
355
+ help="Also write SARIF 2.1.0 output (for GitHub Code Scanning)",
356
+ )
357
+ parser.add_argument(
358
+ "--sarif-output",
359
+ metavar="PATH",
360
+ default="results.sarif",
361
+ help="Path to write SARIF output to (default: results.sarif)",
362
+ )
363
+ parser.add_argument(
364
+ "--exclude",
365
+ metavar="DIR",
366
+ action="append",
367
+ default=[],
368
+ help=(
369
+ "Directory name to exclude (can be repeated). "
370
+ "Added on top of the built-in defaults: "
371
+ + ", ".join(sorted(DEFAULT_EXCLUDES))
372
+ ),
373
+ )
374
+ parser.add_argument("--no-test-files", action="store_true", help="Exclude test files from the scan entirely")
375
+ parser.add_argument(
376
+ "--downgrade-in-tests",
377
+ action="store_true",
378
+ help="Downgrade ALL violations in test files from CRITICAL to WARNING",
379
+ )
380
+ parser.add_argument(
381
+ "--baseline",
382
+ action="store_true",
383
+ help="Only report findings not already present in the baseline (see `pyvibe baseline create`)",
384
+ )
385
+ parser.add_argument(
386
+ "--baseline-path",
387
+ metavar="PATH",
388
+ default=DEFAULT_BASELINE_PATH,
389
+ help=f"Path to the baseline file (default: {DEFAULT_BASELINE_PATH})",
390
+ )
391
+ args = parser.parse_args(argv)
392
+
393
+ file_results = _resolve_file_results(args)
394
+ file_results = _apply_baseline_filter(args, file_results)
395
+
396
+ total_violations = sum(len(v) for v in file_results.values())
397
+ total_files = sum(1 for v in file_results.values() if v)
398
+
399
+ _emit_scan_output(args, file_results, total_violations, total_files)
400
+
401
+ sys.exit(1 if total_violations > 0 else 0)
402
+
403
+
404
+ def _main_baseline(argv):
405
+ parser = argparse.ArgumentParser(
406
+ prog="pyvibe baseline",
407
+ description="Snapshot existing findings so future scans only report new ones",
408
+ )
409
+ parser.add_argument("action", choices=["create", "update"], help="create: refuses to overwrite an existing baseline; update: always overwrites")
410
+ parser.add_argument("path", nargs="?", default=".", help="File or directory to scan (default: current directory)")
411
+ parser.add_argument(
412
+ "--baseline-path",
413
+ metavar="PATH",
414
+ default=DEFAULT_BASELINE_PATH,
415
+ help=f"Path to write the baseline to (default: {DEFAULT_BASELINE_PATH})",
416
+ )
417
+ parser.add_argument(
418
+ "--exclude",
419
+ metavar="DIR",
420
+ action="append",
421
+ default=[],
422
+ help="Directory name to exclude (can be repeated)",
423
+ )
424
+ parser.add_argument("--no-test-files", action="store_true", help="Exclude test files from the baseline entirely")
425
+ args = parser.parse_args(argv)
426
+
427
+ if args.action == "create" and Path(args.baseline_path).exists():
428
+ print(
429
+ f"Error: {args.baseline_path} already exists. "
430
+ "Use `pyvibe baseline update` to overwrite it.",
431
+ file=sys.stderr,
432
+ )
433
+ sys.exit(2)
434
+
435
+ target = Path(args.path)
436
+ if not target.exists():
437
+ print(f"Error: {target} does not exist", file=sys.stderr)
438
+ sys.exit(2)
439
+
440
+ exclude = DEFAULT_EXCLUDES | frozenset(args.exclude)
441
+
442
+ if target.is_file():
443
+ if target.suffix != ".py":
444
+ file_results = {}
445
+ elif args.no_test_files and _is_test_file(str(target)):
446
+ file_results = {}
447
+ else:
448
+ file_results = {target: analyze_file(target)}
449
+ else:
450
+ file_results = analyze_directory(target, exclude=exclude, skip_test_files=args.no_test_files)
451
+
452
+ from pyvibe.baseline import write_baseline
453
+
454
+ total_findings = sum(len(v) for v in file_results.values())
455
+ write_baseline(file_results, args.baseline_path)
456
+
457
+ verb = "created" if args.action == "create" else "updated"
458
+ print(f"Baseline {verb}: {total_findings} findings saved to {args.baseline_path}")
459
+ sys.exit(0)
460
+
461
+
288
462
  def _main_explain(argv):
289
463
  parser = argparse.ArgumentParser(
290
464
  prog="pyvibe explain",
@@ -0,0 +1,234 @@
1
+ """Tests for baseline support (pyvibe/baseline.py + `pyvibe baseline` /
2
+ `pyvibe scan --baseline` CLI subcommands)."""
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+
11
+ import pytest
12
+
13
+ from pyvibe.analyzer import analyze_source
14
+ from pyvibe.baseline import (
15
+ BaselineNotFoundError,
16
+ build_baseline,
17
+ filter_new_violations,
18
+ load_baseline,
19
+ write_baseline,
20
+ )
21
+
22
+ REPO_ROOT = Path(__file__).resolve().parent.parent
23
+
24
+ VIOLATION_SRC = """\
25
+ import time
26
+
27
+ async def handler():
28
+ time.sleep(5)
29
+
30
+ async def other():
31
+ time.sleep(9)
32
+ """
33
+
34
+
35
+ # ─── build_baseline() / write_baseline() / load_baseline() unit tests ──────
36
+
37
+ def test_build_baseline_shapes_output():
38
+ violations = analyze_source(VIOLATION_SRC, filepath="app.py")
39
+ baseline = build_baseline({Path("app.py"): violations})
40
+
41
+ assert baseline == {
42
+ "app.py": [
43
+ {"rule_id": "PYVIBE-001", "line": 4, "message": violations[0].message},
44
+ {"rule_id": "PYVIBE-001", "line": 7, "message": violations[1].message},
45
+ ]
46
+ }
47
+
48
+
49
+ def test_write_and_load_baseline_roundtrip(tmp_path):
50
+ violations = analyze_source(VIOLATION_SRC, filepath="app.py")
51
+ out = tmp_path / "baseline.json"
52
+
53
+ write_baseline({Path("app.py"): violations}, out)
54
+ loaded = load_baseline(out)
55
+
56
+ assert loaded == build_baseline({Path("app.py"): violations})
57
+
58
+
59
+ def test_load_baseline_missing_file_raises(tmp_path):
60
+ with pytest.raises(BaselineNotFoundError, match="No baseline found"):
61
+ load_baseline(tmp_path / "nope.json")
62
+
63
+
64
+ def test_load_baseline_invalid_json_raises(tmp_path):
65
+ bad = tmp_path / "bad.json"
66
+ bad.write_text("not json{{{")
67
+ with pytest.raises(BaselineNotFoundError, match="not valid JSON"):
68
+ load_baseline(bad)
69
+
70
+
71
+ # ─── filter_new_violations() unit tests ────────────────────────────────────
72
+
73
+ def test_filter_new_violations_excludes_exact_match():
74
+ violations = analyze_source(VIOLATION_SRC, filepath="app.py")
75
+ baseline = build_baseline({Path("app.py"): violations})
76
+
77
+ result = filter_new_violations({Path("app.py"): violations}, baseline)
78
+
79
+ assert result == {}
80
+
81
+
82
+ def test_filter_new_violations_reports_new_line():
83
+ violations = analyze_source(VIOLATION_SRC, filepath="app.py")
84
+ baseline = build_baseline({Path("app.py"): [violations[0]]}) # only line 4 known
85
+
86
+ result = filter_new_violations({Path("app.py"): violations}, baseline)
87
+
88
+ assert list(result.keys()) == [Path("app.py")]
89
+ assert [v.line for v in result[Path("app.py")]] == [7]
90
+
91
+
92
+ def test_filter_new_violations_reports_new_rule_on_same_line():
93
+ violations = analyze_source(VIOLATION_SRC, filepath="app.py")
94
+ baseline = {"app.py": [{"rule_id": "PYVIBE-999", "line": 4, "message": "unrelated"}]}
95
+
96
+ result = filter_new_violations({Path("app.py"): violations}, baseline)
97
+
98
+ assert {v.line for v in result[Path("app.py")]} == {4, 7}
99
+
100
+
101
+ def test_filter_new_violations_empty_baseline_returns_everything():
102
+ violations = analyze_source(VIOLATION_SRC, filepath="app.py")
103
+
104
+ result = filter_new_violations({Path("app.py"): violations}, {})
105
+
106
+ assert result == {Path("app.py"): violations}
107
+
108
+
109
+ def test_filter_new_violations_drops_file_with_no_remaining_new_violations():
110
+ violations = analyze_source(VIOLATION_SRC, filepath="app.py")
111
+ baseline = build_baseline({Path("app.py"): violations})
112
+
113
+ clean_file_results = {Path("app.py"): violations, Path("clean.py"): []}
114
+ result = filter_new_violations(clean_file_results, baseline)
115
+
116
+ assert result == {}
117
+
118
+
119
+ # ─── `pyvibe baseline` / `pyvibe scan --baseline` CLI (subprocess) ─────────
120
+
121
+ def _run_cli(args, cwd):
122
+ # cwd is a throwaway tmp_path, not REPO_ROOT, so `-m pyvibe` needs
123
+ # PYTHONPATH set explicitly to find the package in a test environment
124
+ # where it isn't pip-installed (see the review-subcommand CI fix).
125
+ env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)}
126
+ return subprocess.run(
127
+ [sys.executable, "-m", "pyvibe", *args],
128
+ cwd=cwd,
129
+ capture_output=True,
130
+ text=True,
131
+ env=env,
132
+ )
133
+
134
+
135
+ def test_cli_baseline_create_writes_file_and_message(tmp_path):
136
+ (tmp_path / "app.py").write_text(VIOLATION_SRC)
137
+
138
+ result = _run_cli(["baseline", "create", "."], cwd=tmp_path)
139
+
140
+ assert result.returncode == 0
141
+ assert "Baseline created: 2 findings saved to .pyvibe-baseline.json" in result.stdout
142
+ baseline_file = tmp_path / ".pyvibe-baseline.json"
143
+ assert baseline_file.exists()
144
+ assert len(json.loads(baseline_file.read_text())["app.py"]) == 2
145
+
146
+
147
+ def test_cli_baseline_create_refuses_to_overwrite_existing(tmp_path):
148
+ (tmp_path / "app.py").write_text(VIOLATION_SRC)
149
+ _run_cli(["baseline", "create", "."], cwd=tmp_path)
150
+
151
+ result = _run_cli(["baseline", "create", "."], cwd=tmp_path)
152
+
153
+ assert result.returncode == 2
154
+ assert "already exists" in result.stderr
155
+
156
+
157
+ def test_cli_baseline_update_overwrites_existing(tmp_path):
158
+ (tmp_path / "app.py").write_text(VIOLATION_SRC)
159
+ _run_cli(["baseline", "create", "."], cwd=tmp_path)
160
+
161
+ (tmp_path / "app.py").write_text(VIOLATION_SRC + "\nasync def third():\n time.sleep(1)\n")
162
+ result = _run_cli(["baseline", "update", "."], cwd=tmp_path)
163
+
164
+ assert result.returncode == 0
165
+ assert "Baseline updated: 3 findings saved to .pyvibe-baseline.json" in result.stdout
166
+
167
+
168
+ def test_cli_scan_baseline_reports_only_new_finding(tmp_path):
169
+ (tmp_path / "app.py").write_text(VIOLATION_SRC)
170
+ _run_cli(["baseline", "create", "."], cwd=tmp_path)
171
+
172
+ (tmp_path / "app.py").write_text(
173
+ VIOLATION_SRC + "\nasync def third():\n time.sleep(1)\n"
174
+ )
175
+
176
+ result = _run_cli(["scan", ".", "--baseline", "--json"], cwd=tmp_path)
177
+ output = json.loads(result.stdout)
178
+
179
+ assert result.returncode == 1
180
+ assert len(output) == 1
181
+ assert output[0]["line"] == 10
182
+
183
+
184
+ def test_cli_scan_baseline_clean_when_no_new_findings(tmp_path):
185
+ (tmp_path / "app.py").write_text(VIOLATION_SRC)
186
+ _run_cli(["baseline", "create", "."], cwd=tmp_path)
187
+
188
+ result = _run_cli(["scan", ".", "--baseline"], cwd=tmp_path)
189
+
190
+ assert result.returncode == 0
191
+ assert "No violations found" in result.stdout
192
+
193
+
194
+ def test_cli_scan_baseline_missing_file_exits_2(tmp_path):
195
+ (tmp_path / "app.py").write_text("print('clean')\n")
196
+
197
+ result = _run_cli(["scan", ".", "--baseline"], cwd=tmp_path)
198
+
199
+ assert result.returncode == 2
200
+ assert "No baseline found" in result.stderr
201
+
202
+
203
+ def test_cli_scan_without_baseline_flag_matches_bare_invocation(tmp_path):
204
+ (tmp_path / "app.py").write_text(VIOLATION_SRC)
205
+
206
+ scan_result = _run_cli(["scan", ".", "--json"], cwd=tmp_path)
207
+ bare_result = _run_cli([".", "--json"], cwd=tmp_path)
208
+
209
+ assert json.loads(scan_result.stdout) == json.loads(bare_result.stdout)
210
+
211
+
212
+ def test_cli_bare_command_supports_baseline_flag(tmp_path):
213
+ (tmp_path / "app.py").write_text(VIOLATION_SRC)
214
+ _run_cli(["baseline", "create", "."], cwd=tmp_path)
215
+
216
+ (tmp_path / "app.py").write_text(
217
+ VIOLATION_SRC + "\nasync def third():\n time.sleep(1)\n"
218
+ )
219
+
220
+ result = _run_cli([".", "--baseline", "--json"], cwd=tmp_path)
221
+ output = json.loads(result.stdout)
222
+
223
+ assert result.returncode == 1
224
+ assert len(output) == 1
225
+ assert output[0]["line"] == 10
226
+
227
+
228
+ def test_cli_bare_command_baseline_missing_file_exits_2(tmp_path):
229
+ (tmp_path / "app.py").write_text("print('clean')\n")
230
+
231
+ result = _run_cli([".", "--baseline"], cwd=tmp_path)
232
+
233
+ assert result.returncode == 2
234
+ assert "No baseline found" in result.stderr
@@ -176,11 +176,16 @@ def test_analyze_file_without_line_filter_returns_everything(tmp_path):
176
176
  # ─── `pyvibe review` CLI (subprocess, end-to-end against a real git repo) ──
177
177
 
178
178
  def _run_cli(args, cwd):
179
+ # cwd here is a throwaway temp git repo, not REPO_ROOT, so `-m pyvibe`
180
+ # can't rely on cwd-insertion to find the package — it must be told
181
+ # explicitly via PYTHONPATH (pyvibe isn't installed in the test env).
182
+ env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)}
179
183
  return subprocess.run(
180
184
  [sys.executable, "-m", "pyvibe", *args],
181
185
  cwd=cwd,
182
186
  capture_output=True,
183
187
  text=True,
188
+ env=env,
184
189
  )
185
190
 
186
191
 
@@ -1 +0,0 @@
1
- __version__ = "0.10.0"