python-vibe-guard 0.7.1__tar.gz → 0.8.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 (43) hide show
  1. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/PKG-INFO +63 -2
  2. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/README.md +62 -1
  3. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyproject.toml +1 -1
  4. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/PKG-INFO +63 -2
  5. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/SOURCES.txt +6 -1
  6. python_vibe_guard-0.8.0/pyvibe/__init__.py +1 -0
  7. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/cli.py +45 -0
  8. python_vibe_guard-0.8.0/pyvibe/explain.py +157 -0
  9. python_vibe_guard-0.8.0/pyvibe/rule_docs.py +64 -0
  10. python_vibe_guard-0.8.0/pyvibe/sarif.py +95 -0
  11. python_vibe_guard-0.8.0/tests/test_explain.py +112 -0
  12. python_vibe_guard-0.8.0/tests/test_sarif.py +116 -0
  13. python_vibe_guard-0.7.1/pyvibe/__init__.py +0 -1
  14. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
  15. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
  16. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
  17. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/__main__.py +0 -0
  18. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/analyzer.py +0 -0
  19. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/__init__.py +0 -0
  20. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/async_requests.py +0 -0
  21. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/async_sleep.py +0 -0
  22. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/asyncio_run.py +0 -0
  23. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/base.py +0 -0
  24. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/celery_time_limit.py +0 -0
  25. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
  26. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/create_task_orphan.py +0 -0
  27. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
  28. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
  29. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/httpx_client_sync.py +0 -0
  30. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/httpx_sync.py +0 -0
  31. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
  32. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/open_async.py +0 -0
  33. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/os_blocking.py +0 -0
  34. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/queue_put_nowait.py +0 -0
  35. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/retry_no_backoff.py +0 -0
  36. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/silent_except.py +0 -0
  37. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/sqlite_async.py +0 -0
  38. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/subprocess_async.py +0 -0
  39. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/threading_lock.py +0 -0
  40. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/while_true_no_await.py +0 -0
  41. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/setup.cfg +0 -0
  42. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/tests/test_exclude.py +0 -0
  43. {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/tests/test_rules.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-vibe-guard
3
- Version: 0.7.1
3
+ Version: 0.8.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
@@ -163,9 +163,16 @@ python -m pyvibe src/
163
163
  # JSON output for CI/CD pipelines
164
164
  python -m pyvibe src/ --json
165
165
 
166
+ # SARIF 2.1.0 output for GitHub Code Scanning (writes results.sarif)
167
+ python -m pyvibe src/ --sarif
168
+ python -m pyvibe src/ --sarif --sarif-output custom-path.sarif
169
+
166
170
  # Exclude directories (adds to built-in defaults: venv, .venv, __pycache__, …)
167
171
  python -m pyvibe src/ --exclude tests
168
172
 
173
+ # Show the research evidence behind a rule (accuracy, false positives, sources)
174
+ python -m pyvibe explain PYVIBE-002
175
+
169
176
  # Exit code: 0 = clean, 1 = violations found, 2 = path error
170
177
  ```
171
178
 
@@ -201,6 +208,42 @@ python -m pyvibe src/ --exclude tests
201
208
  4 violation(s) in 1 file(s)
202
209
  ```
203
210
 
211
+ ### `pyvibe explain`
212
+
213
+ Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
214
+ evidence file with repo-sweep data, an evidence-level grade, and a hit-by-hit precision
215
+ audit. `pyvibe explain` surfaces that in the terminal instead of making you go dig for it:
216
+
217
+ ```bash
218
+ python -m pyvibe explain PYVIBE-002
219
+ ```
220
+
221
+ ```
222
+ PYVIBE-002 — requests.* inside async def
223
+ ───────────────────────────────────────────────
224
+
225
+ Problema:
226
+ requests.* inside async def
227
+
228
+ Por qué ocurre:
229
+ `requests` is a synchronous HTTP library. Calling it inside an async function
230
+ blocks the OS thread running the event loop. Under concurrent load this
231
+ serialises all I/O and eliminates any benefit of async.
232
+
233
+ Visto en: 4.0% (10/250 repos, sweep-250 dataset)
234
+ Nivel de evidencia: B
235
+ Precisión auditada: ~83%
236
+ Falsos positivos conocidos: EXECUTOR_WRAPPER; INNER_SYNC_FUNCTION_EXECUTOR; ...
237
+
238
+ Fix sugerido:
239
+ use `httpx.AsyncClient` or `aiohttp.ClientSession` with await.
240
+
241
+ Full report: research/accepted/PYVIBE-002.md
242
+ ```
243
+
244
+ If a rule has no evidence file, it prints a clear `No evidence file found for PYVIBE-XXX`
245
+ and exits non-zero — it never invents data.
246
+
204
247
  ---
205
248
 
206
249
  ## CI/CD integration
@@ -216,6 +259,24 @@ Add to your GitHub Actions workflow:
216
259
 
217
260
  The scanner exits with code `1` when violations are found, failing the CI job.
218
261
 
262
+ ### GitHub Code Scanning (SARIF)
263
+
264
+ ```yaml
265
+ - name: python-vibe-guard scan
266
+ run: |
267
+ pip install python-vibe-guard
268
+ python -m pyvibe src/ --sarif
269
+ continue-on-error: true # let the upload step surface results in the PR instead
270
+
271
+ - name: Upload SARIF
272
+ uses: github/codeql-action/upload-sarif@v3
273
+ with:
274
+ sarif_file: results.sarif
275
+ ```
276
+
277
+ Findings then show up as annotations on the PR diff and in the repo's Security tab,
278
+ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `helpUri`.
279
+
219
280
  ---
220
281
 
221
282
  ## Pre-commit integration
@@ -268,7 +329,7 @@ python -m pytest tests/ -v
268
329
  python tests/test_rules.py
269
330
  ```
270
331
 
271
- 123 tests: true positives + false-positive guards for every rule.
332
+ 226 tests: true positives + false-positive guards for every rule, plus SARIF output and `pyvibe explain` coverage.
272
333
 
273
334
  ---
274
335
 
@@ -148,9 +148,16 @@ python -m pyvibe src/
148
148
  # JSON output for CI/CD pipelines
149
149
  python -m pyvibe src/ --json
150
150
 
151
+ # SARIF 2.1.0 output for GitHub Code Scanning (writes results.sarif)
152
+ python -m pyvibe src/ --sarif
153
+ python -m pyvibe src/ --sarif --sarif-output custom-path.sarif
154
+
151
155
  # Exclude directories (adds to built-in defaults: venv, .venv, __pycache__, …)
152
156
  python -m pyvibe src/ --exclude tests
153
157
 
158
+ # Show the research evidence behind a rule (accuracy, false positives, sources)
159
+ python -m pyvibe explain PYVIBE-002
160
+
154
161
  # Exit code: 0 = clean, 1 = violations found, 2 = path error
155
162
  ```
156
163
 
@@ -186,6 +193,42 @@ python -m pyvibe src/ --exclude tests
186
193
  4 violation(s) in 1 file(s)
187
194
  ```
188
195
 
196
+ ### `pyvibe explain`
197
+
198
+ Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
199
+ evidence file with repo-sweep data, an evidence-level grade, and a hit-by-hit precision
200
+ audit. `pyvibe explain` surfaces that in the terminal instead of making you go dig for it:
201
+
202
+ ```bash
203
+ python -m pyvibe explain PYVIBE-002
204
+ ```
205
+
206
+ ```
207
+ PYVIBE-002 — requests.* inside async def
208
+ ───────────────────────────────────────────────
209
+
210
+ Problema:
211
+ requests.* inside async def
212
+
213
+ Por qué ocurre:
214
+ `requests` is a synchronous HTTP library. Calling it inside an async function
215
+ blocks the OS thread running the event loop. Under concurrent load this
216
+ serialises all I/O and eliminates any benefit of async.
217
+
218
+ Visto en: 4.0% (10/250 repos, sweep-250 dataset)
219
+ Nivel de evidencia: B
220
+ Precisión auditada: ~83%
221
+ Falsos positivos conocidos: EXECUTOR_WRAPPER; INNER_SYNC_FUNCTION_EXECUTOR; ...
222
+
223
+ Fix sugerido:
224
+ use `httpx.AsyncClient` or `aiohttp.ClientSession` with await.
225
+
226
+ Full report: research/accepted/PYVIBE-002.md
227
+ ```
228
+
229
+ If a rule has no evidence file, it prints a clear `No evidence file found for PYVIBE-XXX`
230
+ and exits non-zero — it never invents data.
231
+
189
232
  ---
190
233
 
191
234
  ## CI/CD integration
@@ -201,6 +244,24 @@ Add to your GitHub Actions workflow:
201
244
 
202
245
  The scanner exits with code `1` when violations are found, failing the CI job.
203
246
 
247
+ ### GitHub Code Scanning (SARIF)
248
+
249
+ ```yaml
250
+ - name: python-vibe-guard scan
251
+ run: |
252
+ pip install python-vibe-guard
253
+ python -m pyvibe src/ --sarif
254
+ continue-on-error: true # let the upload step surface results in the PR instead
255
+
256
+ - name: Upload SARIF
257
+ uses: github/codeql-action/upload-sarif@v3
258
+ with:
259
+ sarif_file: results.sarif
260
+ ```
261
+
262
+ Findings then show up as annotations on the PR diff and in the repo's Security tab,
263
+ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `helpUri`.
264
+
204
265
  ---
205
266
 
206
267
  ## Pre-commit integration
@@ -253,7 +314,7 @@ python -m pytest tests/ -v
253
314
  python tests/test_rules.py
254
315
  ```
255
316
 
256
- 123 tests: true positives + false-positive guards for every rule.
317
+ 226 tests: true positives + false-positive guards for every rule, plus SARIF output and `pyvibe explain` coverage.
257
318
 
258
319
  ---
259
320
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-vibe-guard"
7
- version = "0.7.1"
7
+ version = "0.8.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.7.1
3
+ Version: 0.8.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
@@ -163,9 +163,16 @@ python -m pyvibe src/
163
163
  # JSON output for CI/CD pipelines
164
164
  python -m pyvibe src/ --json
165
165
 
166
+ # SARIF 2.1.0 output for GitHub Code Scanning (writes results.sarif)
167
+ python -m pyvibe src/ --sarif
168
+ python -m pyvibe src/ --sarif --sarif-output custom-path.sarif
169
+
166
170
  # Exclude directories (adds to built-in defaults: venv, .venv, __pycache__, …)
167
171
  python -m pyvibe src/ --exclude tests
168
172
 
173
+ # Show the research evidence behind a rule (accuracy, false positives, sources)
174
+ python -m pyvibe explain PYVIBE-002
175
+
169
176
  # Exit code: 0 = clean, 1 = violations found, 2 = path error
170
177
  ```
171
178
 
@@ -201,6 +208,42 @@ python -m pyvibe src/ --exclude tests
201
208
  4 violation(s) in 1 file(s)
202
209
  ```
203
210
 
211
+ ### `pyvibe explain`
212
+
213
+ Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
214
+ evidence file with repo-sweep data, an evidence-level grade, and a hit-by-hit precision
215
+ audit. `pyvibe explain` surfaces that in the terminal instead of making you go dig for it:
216
+
217
+ ```bash
218
+ python -m pyvibe explain PYVIBE-002
219
+ ```
220
+
221
+ ```
222
+ PYVIBE-002 — requests.* inside async def
223
+ ───────────────────────────────────────────────
224
+
225
+ Problema:
226
+ requests.* inside async def
227
+
228
+ Por qué ocurre:
229
+ `requests` is a synchronous HTTP library. Calling it inside an async function
230
+ blocks the OS thread running the event loop. Under concurrent load this
231
+ serialises all I/O and eliminates any benefit of async.
232
+
233
+ Visto en: 4.0% (10/250 repos, sweep-250 dataset)
234
+ Nivel de evidencia: B
235
+ Precisión auditada: ~83%
236
+ Falsos positivos conocidos: EXECUTOR_WRAPPER; INNER_SYNC_FUNCTION_EXECUTOR; ...
237
+
238
+ Fix sugerido:
239
+ use `httpx.AsyncClient` or `aiohttp.ClientSession` with await.
240
+
241
+ Full report: research/accepted/PYVIBE-002.md
242
+ ```
243
+
244
+ If a rule has no evidence file, it prints a clear `No evidence file found for PYVIBE-XXX`
245
+ and exits non-zero — it never invents data.
246
+
204
247
  ---
205
248
 
206
249
  ## CI/CD integration
@@ -216,6 +259,24 @@ Add to your GitHub Actions workflow:
216
259
 
217
260
  The scanner exits with code `1` when violations are found, failing the CI job.
218
261
 
262
+ ### GitHub Code Scanning (SARIF)
263
+
264
+ ```yaml
265
+ - name: python-vibe-guard scan
266
+ run: |
267
+ pip install python-vibe-guard
268
+ python -m pyvibe src/ --sarif
269
+ continue-on-error: true # let the upload step surface results in the PR instead
270
+
271
+ - name: Upload SARIF
272
+ uses: github/codeql-action/upload-sarif@v3
273
+ with:
274
+ sarif_file: results.sarif
275
+ ```
276
+
277
+ Findings then show up as annotations on the PR diff and in the repo's Security tab,
278
+ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `helpUri`.
279
+
219
280
  ---
220
281
 
221
282
  ## Pre-commit integration
@@ -268,7 +329,7 @@ python -m pytest tests/ -v
268
329
  python tests/test_rules.py
269
330
  ```
270
331
 
271
- 123 tests: true positives + false-positive guards for every rule.
332
+ 226 tests: true positives + false-positive guards for every rule, plus SARIF output and `pyvibe explain` coverage.
272
333
 
273
334
  ---
274
335
 
@@ -9,6 +9,9 @@ pyvibe/__init__.py
9
9
  pyvibe/__main__.py
10
10
  pyvibe/analyzer.py
11
11
  pyvibe/cli.py
12
+ pyvibe/explain.py
13
+ pyvibe/rule_docs.py
14
+ pyvibe/sarif.py
12
15
  pyvibe/rules/__init__.py
13
16
  pyvibe/rules/async_requests.py
14
17
  pyvibe/rules/async_sleep.py
@@ -32,4 +35,6 @@ pyvibe/rules/subprocess_async.py
32
35
  pyvibe/rules/threading_lock.py
33
36
  pyvibe/rules/while_true_no_await.py
34
37
  tests/test_exclude.py
35
- tests/test_rules.py
38
+ tests/test_explain.py
39
+ tests/test_rules.py
40
+ tests/test_sarif.py
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
@@ -5,8 +5,10 @@ python-vibe-guard — runtime anti-pattern scanner for async Python
5
5
  Usage:
6
6
  python -m pyvibe <path> # file or directory
7
7
  python -m pyvibe <path> --json # machine-readable output
8
+ python -m pyvibe <path> --sarif # SARIF 2.1.0 -> results.sarif
8
9
  python -m pyvibe <path> --no-test-files # skip test files entirely
9
10
  python -m pyvibe <path> --downgrade-in-tests # WARNING instead of CRITICAL in all test files
11
+ python -m pyvibe explain PYVIBE-002 # show research evidence for a rule
10
12
  """
11
13
  import argparse
12
14
  import json
@@ -25,6 +27,10 @@ from pyvibe.analyzer import (
25
27
 
26
28
 
27
29
  def main():
30
+ if len(sys.argv) > 1 and sys.argv[1] == "explain":
31
+ _main_explain(sys.argv[2:])
32
+ return
33
+
28
34
  parser = argparse.ArgumentParser(
29
35
  prog="pyvibe",
30
36
  description="Detect runtime anti-patterns in async Python code",
@@ -32,6 +38,17 @@ def main():
32
38
  parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
33
39
  parser.add_argument("path", help="File or directory to scan")
34
40
  parser.add_argument("--json", action="store_true", help="Output as JSON")
41
+ parser.add_argument(
42
+ "--sarif",
43
+ action="store_true",
44
+ help="Also write SARIF 2.1.0 output (for GitHub Code Scanning)",
45
+ )
46
+ parser.add_argument(
47
+ "--sarif-output",
48
+ metavar="PATH",
49
+ default="results.sarif",
50
+ help="Path to write SARIF output to (default: results.sarif)",
51
+ )
35
52
  parser.add_argument(
36
53
  "--exclude",
37
54
  metavar="DIR",
@@ -97,6 +114,12 @@ def main():
97
114
  total_violations = sum(len(v) for v in file_results.values())
98
115
  total_files = sum(1 for v in file_results.values() if v)
99
116
 
117
+ if args.sarif:
118
+ from pyvibe.sarif import write_sarif
119
+
120
+ write_sarif(file_results, args.sarif_output)
121
+ print(f"SARIF results written to {args.sarif_output}")
122
+
100
123
  if args.json:
101
124
  output = []
102
125
  for path, violations in file_results.items():
@@ -144,5 +167,27 @@ def _print_human(file_results: dict, total_violations: int, total_files: int):
144
167
  print()
145
168
 
146
169
 
170
+ def _main_explain(argv):
171
+ parser = argparse.ArgumentParser(
172
+ prog="pyvibe explain",
173
+ description="Show the research evidence behind a python-vibe-guard rule",
174
+ )
175
+ parser.add_argument("rule_id", help="Rule ID, e.g. PYVIBE-002")
176
+ args = parser.parse_args(argv)
177
+
178
+ from pyvibe.explain import EvidenceNotFoundError, explain_rule
179
+
180
+ try:
181
+ text = explain_rule(args.rule_id)
182
+ except EvidenceNotFoundError as e:
183
+ print(str(e))
184
+ sys.exit(1)
185
+
186
+ print()
187
+ print(text)
188
+ print()
189
+ sys.exit(0)
190
+
191
+
147
192
  if __name__ == "__main__":
148
193
  main()
@@ -0,0 +1,157 @@
1
+ """`pyvibe explain PYVIBE-XXX` — surfaces the research evidence behind a rule
2
+ in the terminal, so the CLI's "trust us" claims are one command away from
3
+ the actual data (research/accepted/PYVIBE-XXX.md, research/precision-audit.md).
4
+ """
5
+ import re
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ from pyvibe.analyzer import ALL_RULES
10
+ from pyvibe.rule_docs import parse_rule_docstring
11
+
12
+ RULES_BY_ID = {cls.RULE_ID: cls for cls in ALL_RULES}
13
+
14
+ _PCT_RE = re.compile(r"(\d+)/(\d+)\s*\(([\d.]+)%\)")
15
+ _EVIDENCE_RE = re.compile(r"Evidence Level:?\s*\*{0,2}\s*([A-D]\+?)\b")
16
+ _FP_SECTION_RE = re.compile(
17
+ r"### Patrones de FP identificados\s*\n(.*?)(?=\n##|\Z)", re.DOTALL
18
+ )
19
+ _FP_PATTERN_NAME_RE = re.compile(r"\*\*([^*]+)\*\*\s*—")
20
+ _VALID_RULE_ID_RE = re.compile(r"^PYVIBE-\d{3}$")
21
+
22
+
23
+ class EvidenceNotFoundError(Exception):
24
+ pass
25
+
26
+
27
+ def _repo_root() -> Path:
28
+ return Path(__file__).resolve().parent.parent
29
+
30
+
31
+ def _accepted_path(rule_id: str) -> Path:
32
+ return _repo_root() / "research" / "accepted" / f"{rule_id}.md"
33
+
34
+
35
+ def _precision_audit_path() -> Path:
36
+ return _repo_root() / "research" / "precision-audit.md"
37
+
38
+
39
+ def normalize_rule_id(raw: str) -> str:
40
+ raw = raw.strip().upper()
41
+ if raw.startswith("PYVIBE-"):
42
+ return raw
43
+ if raw.isdigit():
44
+ return f"PYVIBE-{int(raw):03d}"
45
+ return raw
46
+
47
+
48
+ def _extract_repo_percentage(doc_text: str) -> str:
49
+ for line in doc_text.splitlines():
50
+ if "Repos afectados" not in line:
51
+ continue
52
+ matches = _PCT_RE.findall(line)
53
+ if not matches:
54
+ return "not reported"
55
+ count, sample, pct = max(matches, key=lambda m: int(m[1]))
56
+ return f"{pct}% ({count}/{sample} repos, sweep-250 dataset)"
57
+ return "not reported"
58
+
59
+
60
+ def _extract_evidence_level(doc_text: str) -> str:
61
+ for line in doc_text.splitlines():
62
+ if "Evidence Level" not in line:
63
+ continue
64
+ m = _EVIDENCE_RE.search(line)
65
+ if m:
66
+ return m.group(1)
67
+ return "not documented"
68
+
69
+
70
+ def _extract_audit_row(rule_id: str) -> Optional[List[str]]:
71
+ path = _precision_audit_path()
72
+ if not path.exists():
73
+ return None
74
+ for line in path.read_text(encoding="utf-8").splitlines():
75
+ stripped = line.strip()
76
+ if stripped.startswith(f"| {rule_id} "):
77
+ return [c.strip() for c in stripped.strip("|").split("|")]
78
+ return None
79
+
80
+
81
+ def _extract_precision(rule_id: str) -> str:
82
+ row = _extract_audit_row(rule_id)
83
+ if row and len(row) >= 8:
84
+ return row[7]
85
+ return "not audited"
86
+
87
+
88
+ def _extract_known_fps(doc_text: str, rule_id: str) -> str:
89
+ m = _FP_SECTION_RE.search(doc_text)
90
+ if m:
91
+ names = _FP_PATTERN_NAME_RE.findall(m.group(1))
92
+ if names:
93
+ return "; ".join(n.strip() for n in names)
94
+
95
+ row = _extract_audit_row(rule_id)
96
+ if row and len(row) >= 6:
97
+ fp, hits, audited = row[5], row[2], row[3]
98
+ return (
99
+ f"{fp} false positive(s) found in {audited} manually audited hits "
100
+ f"(of {hits} total) — see research/precision-audit.md for the "
101
+ f"hit-by-hit classification."
102
+ )
103
+ return "No documented false-positive patterns — see research/precision-audit.md."
104
+
105
+
106
+ def explain_rule(raw_rule_id: str) -> str:
107
+ rule_id = normalize_rule_id(raw_rule_id)
108
+ if not _VALID_RULE_ID_RE.match(rule_id):
109
+ # Reject anything that isn't a bare "PYVIBE-###" *before* it touches
110
+ # a filesystem path — raw_rule_id is attacker-controllable (CLI arg)
111
+ # and would otherwise let e.g. "../../../etc/passwd" traverse out of
112
+ # research/accepted/.
113
+ raise EvidenceNotFoundError(f"No evidence file found for {rule_id}")
114
+
115
+ doc_path = _accepted_path(rule_id)
116
+ if not doc_path.exists():
117
+ raise EvidenceNotFoundError(f"No evidence file found for {rule_id}")
118
+
119
+ doc_text = doc_path.read_text(encoding="utf-8")
120
+
121
+ cls = RULES_BY_ID.get(rule_id)
122
+ if cls is not None:
123
+ parsed = parse_rule_docstring(cls)
124
+ title, why, fix = parsed.title, parsed.why, parsed.fix
125
+ else:
126
+ first_line = doc_text.splitlines()[0].lstrip("# ").strip()
127
+ title, why, fix = first_line, "not available", "not available"
128
+
129
+ repo_pct = _extract_repo_percentage(doc_text)
130
+ evidence_level = _extract_evidence_level(doc_text)
131
+ precision = _extract_precision(rule_id)
132
+ known_fps = _extract_known_fps(doc_text, rule_id)
133
+
134
+ lines = [
135
+ f" {rule_id} — {title}",
136
+ " " + "─" * 47,
137
+ "",
138
+ " Problema:",
139
+ f" {title}",
140
+ "",
141
+ " Por qué ocurre:",
142
+ ]
143
+ for para in why.split("\n\n"):
144
+ lines.append(f" {para}")
145
+ lines += [
146
+ "",
147
+ f" Visto en: {repo_pct}",
148
+ f" Nivel de evidencia: {evidence_level}",
149
+ f" Precisión auditada: {precision}",
150
+ f" Falsos positivos conocidos: {known_fps}",
151
+ "",
152
+ " Fix sugerido:",
153
+ f" {fix}",
154
+ "",
155
+ f" Full report: research/accepted/{rule_id}.md",
156
+ ]
157
+ return "\n".join(lines)
@@ -0,0 +1,64 @@
1
+ """Parses the English docstring every rule class already carries into
2
+ structured (title, mechanism, fix) fields.
3
+
4
+ Used by both `pyvibe explain` and SARIF rule-catalog generation so the
5
+ two surfaces never drift out of sync with each other.
6
+ """
7
+ import inspect
8
+ import re
9
+ from typing import NamedTuple
10
+
11
+ _TITLE_PREFIX_RE = re.compile(r"^PYVIBE-\d+\s*—\s*")
12
+ _WHITESPACE_RE = re.compile(r"\s+")
13
+
14
+
15
+ class RuleDoc(NamedTuple):
16
+ title: str
17
+ why: str
18
+ fix: str
19
+
20
+
21
+ def parse_rule_docstring(cls) -> RuleDoc:
22
+ """Split a rule class's docstring into title / mechanism / fix.
23
+
24
+ Docstring convention (see any file under pyvibe/rules/): the first
25
+ paragraph is "PYVIBE-XXX — <title>", the closing paragraph starting
26
+ with "Fix:" is the suggested fix, and everything in between explains
27
+ the mechanism/runtime effect.
28
+ """
29
+ doc = inspect.getdoc(cls) or ""
30
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", doc) if p.strip()]
31
+ if not paragraphs:
32
+ rule_id = getattr(cls, "RULE_ID", "")
33
+ return RuleDoc(title=rule_id, why="", fix="")
34
+
35
+ title = _TITLE_PREFIX_RE.sub("", paragraphs[0].splitlines()[0]).strip()
36
+
37
+ # The "Fix:" sentence is usually its own paragraph, but a couple of
38
+ # rules (e.g. PYVIBE-005) tack it onto the end of the preceding
39
+ # sentence with no blank line in between — so scan line-by-line
40
+ # within each paragraph rather than requiring "Fix:" at paragraph start.
41
+ fix = ""
42
+ why_parts = []
43
+ for para in paragraphs[1:]:
44
+ plines = para.splitlines()
45
+ fix_idx = next(
46
+ (i for i, line in enumerate(plines) if line.strip().startswith("Fix:")),
47
+ None,
48
+ )
49
+ if fix_idx is None or fix:
50
+ why_parts.append(_WHITESPACE_RE.sub(" ", para))
51
+ continue
52
+
53
+ before_lines = plines[:fix_idx]
54
+ fix_lines = list(plines[fix_idx:])
55
+ fix_lines[0] = fix_lines[0].strip()[len("Fix:"):].strip()
56
+ fix = _WHITESPACE_RE.sub(" ", " ".join(fix_lines)).strip()
57
+ if before_lines:
58
+ why_parts.append(_WHITESPACE_RE.sub(" ", " ".join(before_lines)).strip())
59
+
60
+ return RuleDoc(
61
+ title=title,
62
+ why="\n\n".join(why_parts),
63
+ fix=fix or "See rule source for fix guidance.",
64
+ )
@@ -0,0 +1,95 @@
1
+ """SARIF 2.1.0 output for GitHub Code Scanning (`pyvibe <path> --sarif`)."""
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Dict, List
5
+
6
+ from pyvibe import __version__
7
+ from pyvibe.analyzer import ALL_RULES
8
+ from pyvibe.rule_docs import parse_rule_docstring
9
+ from pyvibe.rules.base import Violation
10
+
11
+ SARIF_SCHEMA_URI = (
12
+ "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/Schemata/"
13
+ "sarif-schema-2.1.0.json"
14
+ )
15
+ REPO_URL = "https://github.com/Joaquinriosheredia/python-vibe-guard"
16
+
17
+ _SEVERITY_TO_LEVEL = {"CRITICAL": "error", "WARNING": "warning"}
18
+
19
+
20
+ def _level_for(severity: str) -> str:
21
+ return _SEVERITY_TO_LEVEL.get(severity, "warning")
22
+
23
+
24
+ def _rule_descriptor(cls) -> dict:
25
+ doc = parse_rule_docstring(cls)
26
+ return {
27
+ "id": cls.RULE_ID,
28
+ "name": cls.__name__,
29
+ "shortDescription": {"text": doc.title},
30
+ "fullDescription": {"text": doc.why or doc.title},
31
+ "help": {"text": doc.fix},
32
+ "helpUri": f"{REPO_URL}/blob/master/research/accepted/{cls.RULE_ID}.md",
33
+ "defaultConfiguration": {"level": _level_for(cls.SEVERITY)},
34
+ }
35
+
36
+
37
+ def _artifact_uri(path) -> str:
38
+ p = Path(path)
39
+ if p.is_absolute():
40
+ try:
41
+ p = p.relative_to(Path.cwd())
42
+ except ValueError:
43
+ pass
44
+ return p.as_posix()
45
+
46
+
47
+ def _result(path, v: Violation) -> dict:
48
+ return {
49
+ "ruleId": v.rule_id,
50
+ "level": _level_for(v.severity),
51
+ "message": {"text": f"{v.message} — Fix: {v.evidence}"},
52
+ "locations": [
53
+ {
54
+ "physicalLocation": {
55
+ "artifactLocation": {"uri": _artifact_uri(path)},
56
+ "region": {"startLine": v.line},
57
+ }
58
+ }
59
+ ],
60
+ "properties": {"function": v.function_name},
61
+ }
62
+
63
+
64
+ def build_sarif(file_results: Dict) -> dict:
65
+ """file_results: {path: [Violation, ...]} as produced by analyze_file /
66
+ analyze_directory. Every rule is listed in the tool's rule catalog
67
+ regardless of whether it fired, per SARIF/Code Scanning convention.
68
+ """
69
+ rules = [_rule_descriptor(cls) for cls in ALL_RULES]
70
+ results: List[dict] = [
71
+ _result(path, v) for path, violations in file_results.items() for v in violations
72
+ ]
73
+
74
+ return {
75
+ "$schema": SARIF_SCHEMA_URI,
76
+ "version": "2.1.0",
77
+ "runs": [
78
+ {
79
+ "tool": {
80
+ "driver": {
81
+ "name": "python-vibe-guard",
82
+ "informationUri": REPO_URL,
83
+ "version": __version__,
84
+ "rules": rules,
85
+ }
86
+ },
87
+ "results": results,
88
+ }
89
+ ],
90
+ }
91
+
92
+
93
+ def write_sarif(file_results: Dict, output_path) -> None:
94
+ sarif = build_sarif(file_results)
95
+ Path(output_path).write_text(json.dumps(sarif, indent=2), encoding="utf-8")
@@ -0,0 +1,112 @@
1
+ """Tests for `pyvibe explain PYVIBE-XXX` (pyvibe/explain.py + CLI subcommand)."""
2
+ import subprocess
3
+ import sys
4
+ import os
5
+ from pathlib import Path
6
+
7
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+
9
+ import pytest
10
+
11
+ from pyvibe.analyzer import ALL_RULE_IDS
12
+ from pyvibe.explain import EvidenceNotFoundError, explain_rule, normalize_rule_id
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parent.parent
15
+
16
+ REQUIRED_SECTIONS = [
17
+ "Problema",
18
+ "Por qué ocurre",
19
+ "Visto en",
20
+ "Nivel de evidencia",
21
+ "Precisión auditada",
22
+ "Falsos positivos conocidos",
23
+ "Fix sugerido",
24
+ ]
25
+
26
+
27
+ # ─── normalize_rule_id ──────────────────────────────────────────────────────
28
+
29
+ def test_normalize_rule_id_passthrough():
30
+ assert normalize_rule_id("PYVIBE-002") == "PYVIBE-002"
31
+
32
+
33
+ def test_normalize_rule_id_lowercase():
34
+ assert normalize_rule_id("pyvibe-002") == "PYVIBE-002"
35
+
36
+
37
+ def test_normalize_rule_id_numeric_shorthand():
38
+ assert normalize_rule_id("2") == "PYVIBE-002"
39
+ assert normalize_rule_id("20") == "PYVIBE-020"
40
+
41
+
42
+ # ─── explain_rule() unit tests ──────────────────────────────────────────────
43
+
44
+ def test_explain_rule_missing_file_raises_clear_error():
45
+ with pytest.raises(EvidenceNotFoundError, match="No evidence file found for PYVIBE-999"):
46
+ explain_rule("PYVIBE-999")
47
+
48
+
49
+ def test_explain_rule_rejects_path_traversal():
50
+ """rule_id is a raw CLI arg — must never escape research/accepted/."""
51
+ with pytest.raises(EvidenceNotFoundError):
52
+ explain_rule("../../../../etc/passwd")
53
+ with pytest.raises(EvidenceNotFoundError):
54
+ explain_rule("../../README")
55
+
56
+
57
+ @pytest.mark.parametrize("rule_id", sorted(ALL_RULE_IDS))
58
+ def test_explain_rule_covers_every_accepted_rule(rule_id):
59
+ """Every shipped rule has a research doc and produces all 7 sections."""
60
+ text = explain_rule(rule_id)
61
+ for section in REQUIRED_SECTIONS:
62
+ assert section in text, f"{rule_id}: missing section {section!r}"
63
+
64
+
65
+ def test_explain_rule_fix_is_not_generic_placeholder():
66
+ text = explain_rule("PYVIBE-002")
67
+ assert "httpx.AsyncClient" in text or "aiohttp" in text
68
+
69
+
70
+ def test_explain_rule_005_fix_extracted_despite_shared_paragraph():
71
+ # Regression: PYVIBE-005's "Fix:" line shares a paragraph with the
72
+ # preceding sentence in the docstring (no blank line separator).
73
+ text = explain_rule("PYVIBE-005")
74
+ assert "soft_time_limit=30" in text
75
+ assert "See rule source for fix guidance." not in text
76
+
77
+
78
+ def test_explain_rule_reports_repo_percentage():
79
+ text = explain_rule("PYVIBE-002")
80
+ assert "%" in text
81
+ assert "250" in text
82
+
83
+
84
+ # ─── CLI subcommand (subprocess, end-to-end) ────────────────────────────────
85
+
86
+ def _run_cli(args, cwd=REPO_ROOT):
87
+ return subprocess.run(
88
+ [sys.executable, "-m", "pyvibe", *args],
89
+ cwd=cwd,
90
+ capture_output=True,
91
+ text=True,
92
+ )
93
+
94
+
95
+ def test_cli_explain_known_rule_exits_zero():
96
+ result = _run_cli(["explain", "PYVIBE-002"])
97
+ assert result.returncode == 0
98
+ assert "PYVIBE-002" in result.stdout
99
+ for section in REQUIRED_SECTIONS:
100
+ assert section in result.stdout
101
+
102
+
103
+ def test_cli_explain_unknown_rule_prints_clear_message_and_exits_nonzero():
104
+ result = _run_cli(["explain", "PYVIBE-999"])
105
+ assert result.returncode == 1
106
+ assert "No evidence file found for PYVIBE-999" in result.stdout
107
+
108
+
109
+ def test_cli_explain_normalizes_numeric_shorthand():
110
+ result = _run_cli(["explain", "2"])
111
+ assert result.returncode == 0
112
+ assert "PYVIBE-002" in result.stdout
@@ -0,0 +1,116 @@
1
+ """Tests for SARIF 2.1.0 output (pyvibe/sarif.py + --sarif CLI flag)."""
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import sys as _sys
9
+ import os
10
+
11
+ _sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ from pyvibe.analyzer import ALL_RULE_IDS, analyze_source
14
+ from pyvibe.sarif import build_sarif
15
+
16
+ REPO_ROOT = Path(__file__).resolve().parent.parent
17
+
18
+ VIOLATION_SRC = """\
19
+ import time
20
+
21
+ async def handler():
22
+ time.sleep(5)
23
+ """
24
+
25
+ CLEAN_SRC = """\
26
+ def handler():
27
+ pass
28
+ """
29
+
30
+
31
+ # ─── build_sarif() unit tests ──────────────────────────────────────────────
32
+
33
+ def test_sarif_schema_version():
34
+ sarif = build_sarif({})
35
+ assert sarif["version"] == "2.1.0"
36
+ assert sarif["$schema"].endswith("sarif-schema-2.1.0.json")
37
+
38
+
39
+ def test_sarif_rule_catalog_lists_all_rules_even_with_no_hits():
40
+ sarif = build_sarif({})
41
+ rule_ids = {r["id"] for r in sarif["runs"][0]["tool"]["driver"]["rules"]}
42
+ assert rule_ids == set(ALL_RULE_IDS)
43
+ assert sarif["runs"][0]["results"] == []
44
+
45
+
46
+ def test_sarif_rule_descriptor_has_help_uri_pointing_to_research_doc():
47
+ sarif = build_sarif({})
48
+ rules_by_id = {r["id"]: r for r in sarif["runs"][0]["tool"]["driver"]["rules"]}
49
+ rule = rules_by_id["PYVIBE-001"]
50
+ assert rule["helpUri"].endswith("research/accepted/PYVIBE-001.md")
51
+ assert rule["shortDescription"]["text"]
52
+ assert rule["fullDescription"]["text"]
53
+
54
+
55
+ def test_sarif_result_has_ruleid_message_and_location():
56
+ violations = analyze_source(VIOLATION_SRC, filepath="handler.py")
57
+ sarif = build_sarif({Path("handler.py"): violations})
58
+ results = sarif["runs"][0]["results"]
59
+ assert len(results) == 1
60
+ result = results[0]
61
+ assert result["ruleId"] == "PYVIBE-001"
62
+ assert result["level"] == "error"
63
+ assert "message" in result and result["message"]["text"]
64
+ loc = result["locations"][0]["physicalLocation"]
65
+ assert loc["artifactLocation"]["uri"] == "handler.py"
66
+ assert loc["region"]["startLine"] == 4
67
+
68
+
69
+ def test_sarif_level_reflects_downgraded_severity():
70
+ # PYVIBE-001 is downgraded to WARNING inside test files by default.
71
+ violations = analyze_source(VIOLATION_SRC, filepath="tests/test_handler.py")
72
+ sarif = build_sarif({Path("tests/test_handler.py"): violations})
73
+ result = sarif["runs"][0]["results"][0]
74
+ assert result["level"] == "warning"
75
+
76
+
77
+ # ─── --sarif CLI flag (subprocess, end-to-end) ─────────────────────────────
78
+
79
+ def _run_cli(args, cwd=REPO_ROOT):
80
+ return subprocess.run(
81
+ [sys.executable, "-m", "pyvibe", *args],
82
+ cwd=cwd,
83
+ capture_output=True,
84
+ text=True,
85
+ )
86
+
87
+
88
+ def test_cli_sarif_flag_writes_file_with_same_exit_code_as_json():
89
+ with tempfile.TemporaryDirectory() as tmp:
90
+ src = Path(tmp) / "bad.py"
91
+ src.write_text(VIOLATION_SRC)
92
+ out = Path(tmp) / "out.sarif"
93
+
94
+ result = _run_cli([str(src), "--sarif", "--sarif-output", str(out)])
95
+ json_result = _run_cli([str(src), "--json"])
96
+
97
+ assert result.returncode == json_result.returncode == 1
98
+ assert out.exists()
99
+ sarif = json.loads(out.read_text())
100
+ assert sarif["version"] == "2.1.0"
101
+ assert len(sarif["runs"][0]["results"]) == 1
102
+ assert sarif["runs"][0]["results"][0]["ruleId"] == "PYVIBE-001"
103
+
104
+
105
+ def test_cli_sarif_flag_clean_scan_exits_zero():
106
+ with tempfile.TemporaryDirectory() as tmp:
107
+ src = Path(tmp) / "clean.py"
108
+ src.write_text(CLEAN_SRC)
109
+ out = Path(tmp) / "out.sarif"
110
+
111
+ result = _run_cli([str(src), "--sarif", "--sarif-output", str(out)])
112
+
113
+ assert result.returncode == 0
114
+ sarif = json.loads(out.read_text())
115
+ assert sarif["runs"][0]["results"] == []
116
+ assert len(sarif["runs"][0]["tool"]["driver"]["rules"]) == 20
@@ -1 +0,0 @@
1
- __version__ = "0.7.0"