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.
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/PKG-INFO +63 -2
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/README.md +62 -1
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyproject.toml +1 -1
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/PKG-INFO +63 -2
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/SOURCES.txt +6 -1
- python_vibe_guard-0.8.0/pyvibe/__init__.py +1 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/cli.py +45 -0
- python_vibe_guard-0.8.0/pyvibe/explain.py +157 -0
- python_vibe_guard-0.8.0/pyvibe/rule_docs.py +64 -0
- python_vibe_guard-0.8.0/pyvibe/sarif.py +95 -0
- python_vibe_guard-0.8.0/tests/test_explain.py +112 -0
- python_vibe_guard-0.8.0/tests/test_sarif.py +116 -0
- python_vibe_guard-0.7.1/pyvibe/__init__.py +0 -1
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/__main__.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/analyzer.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/__init__.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/async_requests.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/async_sleep.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/asyncio_run.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/base.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/celery_time_limit.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/create_task_orphan.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/httpx_client_sync.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/httpx_sync.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/open_async.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/os_blocking.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/queue_put_nowait.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/retry_no_backoff.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/silent_except.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/sqlite_async.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/subprocess_async.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/threading_lock.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/while_true_no_await.py +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/setup.cfg +0 -0
- {python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/tests/test_exclude.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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.
|
|
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
|
-
|
|
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/
|
|
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"
|
{python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/python_vibe_guard.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{python_vibe_guard-0.7.1 → python_vibe_guard-0.8.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
|
{python_vibe_guard-0.7.1 → python_vibe_guard-0.8.0}/pyvibe/rules/gather_no_return_exceptions.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
|