python-vibe-guard 0.12.0__tar.gz → 0.12.1__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.12.0 → python_vibe_guard-0.12.1}/PKG-INFO +53 -2
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/README.md +52 -1
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyproject.toml +1 -1
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/PKG-INFO +53 -2
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/SOURCES.txt +2 -0
- python_vibe_guard-0.12.1/pyvibe/__init__.py +1 -0
- python_vibe_guard-0.12.1/pyvibe/audit.py +119 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/cli.py +134 -0
- python_vibe_guard-0.12.1/pyvibe/suppressions.py +102 -0
- python_vibe_guard-0.12.1/tests/test_audit.py +274 -0
- python_vibe_guard-0.12.0/pyvibe/__init__.py +0 -1
- python_vibe_guard-0.12.0/pyvibe/suppressions.py +0 -53
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/entry_points.txt +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/requires.txt +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/top_level.txt +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/__main__.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/analyzer.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/autofix.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/baseline.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/config.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/diff.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/explain.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rule_docs.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/__init__.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/async_requests.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/async_sleep.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/asyncio_run.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/base.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/celery_time_limit.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/contextvar_cleanup.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/create_task_orphan.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/ensure_future_orphan.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/httpx_client_sync.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/httpx_sync.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/loop_run_until_complete.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/open_async.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/os_blocking.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/queue_put_nowait.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/retry_no_backoff.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/silent_except.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/sqlite_async.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/subprocess_async.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/threading_lock.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/while_true_no_await.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/sarif.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/setup.cfg +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_baseline.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_config.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_diff.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_exclude.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_explain.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_rules.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_sarif.py +0 -0
- {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_suppressions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-vibe-guard
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.1
|
|
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
|
|
@@ -181,6 +181,11 @@ python -m pyvibe src/ --baseline # only reports findings NOT in the bas
|
|
|
181
181
|
# Show suppressed findings (inline comments + pyvibe.toml) alongside the report
|
|
182
182
|
python -m pyvibe src/ --verbose
|
|
183
183
|
|
|
184
|
+
# Audit inline suppressions: justification coverage + orphaned (unused) suppressions
|
|
185
|
+
python -m pyvibe audit src/
|
|
186
|
+
python -m pyvibe audit src/ --json
|
|
187
|
+
python -m pyvibe audit src/ --fail-on-unused --fail-on-unjustified
|
|
188
|
+
|
|
184
189
|
# Exit code: 0 = clean, 1 = violations found, 2 = path error
|
|
185
190
|
```
|
|
186
191
|
|
|
@@ -314,6 +319,14 @@ conn = sqlite3.connect(...) # suppressed — always targets the nex
|
|
|
314
319
|
`pyvibe:` is case-insensitive. A directive with no recognizable `PYVIBE-XXX` id is ignored
|
|
315
320
|
(treated as a regular comment).
|
|
316
321
|
|
|
322
|
+
Add an optional justification after `--` — it's free text, stored and shown in `pyvibe audit`:
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
conn = sqlite3.connect(...) # pyvibe: ignore PYVIBE-008 -- legacy sqlite wrapper
|
|
326
|
+
# pyvibe: ignore-next-line PYVIBE-008 -- startup only
|
|
327
|
+
conn = sqlite3.connect(...)
|
|
328
|
+
```
|
|
329
|
+
|
|
317
330
|
For project-wide rules, add a `pyvibe.toml` next to your code (python-vibe-guard walks up
|
|
318
331
|
from the scan target to find it, same convention as `pyproject.toml`):
|
|
319
332
|
|
|
@@ -346,6 +359,44 @@ Add `--verbose` to see exactly what was suppressed and why:
|
|
|
346
359
|
PYVIBE-019 legacy.py:81 (config)
|
|
347
360
|
```
|
|
348
361
|
|
|
362
|
+
### `pyvibe audit`
|
|
363
|
+
|
|
364
|
+
Audits every inline `# pyvibe: ignore` comment in a codebase: how many carry a justification,
|
|
365
|
+
and how many are **orphaned** — the rule they name never actually fires on the target line,
|
|
366
|
+
usually because the underlying code changed since the suppression was added.
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
$ pyvibe audit src/
|
|
370
|
+
|
|
371
|
+
Suppressions audit
|
|
372
|
+
──────────────────
|
|
373
|
+
Total suppressions: 12
|
|
374
|
+
With justification: 9 (75%)
|
|
375
|
+
Without justification: 3
|
|
376
|
+
Unused (no violation found): 2
|
|
377
|
+
|
|
378
|
+
By rule:
|
|
379
|
+
PYVIBE-008 5
|
|
380
|
+
PYVIBE-019 4
|
|
381
|
+
PYVIBE-003 3
|
|
382
|
+
|
|
383
|
+
Unused suppressions:
|
|
384
|
+
app/legacy.py:41 # pyvibe: ignore PYVIBE-008
|
|
385
|
+
|
|
386
|
+
Without justification:
|
|
387
|
+
models/db.py:88 # pyvibe: ignore PYVIBE-019
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
- `--json` — machine-readable output (`total`, `with_justification`, `without_justification`,
|
|
391
|
+
`unused`, `by_rule`, `unused_suppressions`, `without_justification_suppressions`).
|
|
392
|
+
- `--fail-on-unused` — exit `1` if any orphaned suppression is found.
|
|
393
|
+
- `--fail-on-unjustified` — exit `1` if any suppression is missing a justification.
|
|
394
|
+
- `--max-unused N` — exit `1` if more than `N` orphaned suppressions are found.
|
|
395
|
+
- `--exclude DIR` — same directory-exclusion convention as `pyvibe scan`.
|
|
396
|
+
|
|
397
|
+
With no flags, `pyvibe audit` is purely informational (exit `0`) — the flags above are what
|
|
398
|
+
you wire into CI to enforce a justification/cleanup policy over time.
|
|
399
|
+
|
|
349
400
|
---
|
|
350
401
|
|
|
351
402
|
## CI/CD integration
|
|
@@ -443,7 +494,7 @@ python -m pytest tests/ -v
|
|
|
443
494
|
python tests/test_rules.py
|
|
444
495
|
```
|
|
445
496
|
|
|
446
|
-
|
|
497
|
+
331 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, baseline mode, suppressions (inline comments + pyvibe.toml), and `pyvibe audit` (justifications + orphan detection) coverage.
|
|
447
498
|
|
|
448
499
|
---
|
|
449
500
|
|
|
@@ -165,6 +165,11 @@ python -m pyvibe src/ --baseline # only reports findings NOT in the bas
|
|
|
165
165
|
# Show suppressed findings (inline comments + pyvibe.toml) alongside the report
|
|
166
166
|
python -m pyvibe src/ --verbose
|
|
167
167
|
|
|
168
|
+
# Audit inline suppressions: justification coverage + orphaned (unused) suppressions
|
|
169
|
+
python -m pyvibe audit src/
|
|
170
|
+
python -m pyvibe audit src/ --json
|
|
171
|
+
python -m pyvibe audit src/ --fail-on-unused --fail-on-unjustified
|
|
172
|
+
|
|
168
173
|
# Exit code: 0 = clean, 1 = violations found, 2 = path error
|
|
169
174
|
```
|
|
170
175
|
|
|
@@ -298,6 +303,14 @@ conn = sqlite3.connect(...) # suppressed — always targets the nex
|
|
|
298
303
|
`pyvibe:` is case-insensitive. A directive with no recognizable `PYVIBE-XXX` id is ignored
|
|
299
304
|
(treated as a regular comment).
|
|
300
305
|
|
|
306
|
+
Add an optional justification after `--` — it's free text, stored and shown in `pyvibe audit`:
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
conn = sqlite3.connect(...) # pyvibe: ignore PYVIBE-008 -- legacy sqlite wrapper
|
|
310
|
+
# pyvibe: ignore-next-line PYVIBE-008 -- startup only
|
|
311
|
+
conn = sqlite3.connect(...)
|
|
312
|
+
```
|
|
313
|
+
|
|
301
314
|
For project-wide rules, add a `pyvibe.toml` next to your code (python-vibe-guard walks up
|
|
302
315
|
from the scan target to find it, same convention as `pyproject.toml`):
|
|
303
316
|
|
|
@@ -330,6 +343,44 @@ Add `--verbose` to see exactly what was suppressed and why:
|
|
|
330
343
|
PYVIBE-019 legacy.py:81 (config)
|
|
331
344
|
```
|
|
332
345
|
|
|
346
|
+
### `pyvibe audit`
|
|
347
|
+
|
|
348
|
+
Audits every inline `# pyvibe: ignore` comment in a codebase: how many carry a justification,
|
|
349
|
+
and how many are **orphaned** — the rule they name never actually fires on the target line,
|
|
350
|
+
usually because the underlying code changed since the suppression was added.
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
$ pyvibe audit src/
|
|
354
|
+
|
|
355
|
+
Suppressions audit
|
|
356
|
+
──────────────────
|
|
357
|
+
Total suppressions: 12
|
|
358
|
+
With justification: 9 (75%)
|
|
359
|
+
Without justification: 3
|
|
360
|
+
Unused (no violation found): 2
|
|
361
|
+
|
|
362
|
+
By rule:
|
|
363
|
+
PYVIBE-008 5
|
|
364
|
+
PYVIBE-019 4
|
|
365
|
+
PYVIBE-003 3
|
|
366
|
+
|
|
367
|
+
Unused suppressions:
|
|
368
|
+
app/legacy.py:41 # pyvibe: ignore PYVIBE-008
|
|
369
|
+
|
|
370
|
+
Without justification:
|
|
371
|
+
models/db.py:88 # pyvibe: ignore PYVIBE-019
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- `--json` — machine-readable output (`total`, `with_justification`, `without_justification`,
|
|
375
|
+
`unused`, `by_rule`, `unused_suppressions`, `without_justification_suppressions`).
|
|
376
|
+
- `--fail-on-unused` — exit `1` if any orphaned suppression is found.
|
|
377
|
+
- `--fail-on-unjustified` — exit `1` if any suppression is missing a justification.
|
|
378
|
+
- `--max-unused N` — exit `1` if more than `N` orphaned suppressions are found.
|
|
379
|
+
- `--exclude DIR` — same directory-exclusion convention as `pyvibe scan`.
|
|
380
|
+
|
|
381
|
+
With no flags, `pyvibe audit` is purely informational (exit `0`) — the flags above are what
|
|
382
|
+
you wire into CI to enforce a justification/cleanup policy over time.
|
|
383
|
+
|
|
333
384
|
---
|
|
334
385
|
|
|
335
386
|
## CI/CD integration
|
|
@@ -427,7 +478,7 @@ python -m pytest tests/ -v
|
|
|
427
478
|
python tests/test_rules.py
|
|
428
479
|
```
|
|
429
480
|
|
|
430
|
-
|
|
481
|
+
331 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, baseline mode, suppressions (inline comments + pyvibe.toml), and `pyvibe audit` (justifications + orphan detection) coverage.
|
|
431
482
|
|
|
432
483
|
---
|
|
433
484
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-vibe-guard"
|
|
7
|
-
version = "0.12.
|
|
7
|
+
version = "0.12.1"
|
|
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.12.
|
|
3
|
+
Version: 0.12.1
|
|
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
|
|
@@ -181,6 +181,11 @@ python -m pyvibe src/ --baseline # only reports findings NOT in the bas
|
|
|
181
181
|
# Show suppressed findings (inline comments + pyvibe.toml) alongside the report
|
|
182
182
|
python -m pyvibe src/ --verbose
|
|
183
183
|
|
|
184
|
+
# Audit inline suppressions: justification coverage + orphaned (unused) suppressions
|
|
185
|
+
python -m pyvibe audit src/
|
|
186
|
+
python -m pyvibe audit src/ --json
|
|
187
|
+
python -m pyvibe audit src/ --fail-on-unused --fail-on-unjustified
|
|
188
|
+
|
|
184
189
|
# Exit code: 0 = clean, 1 = violations found, 2 = path error
|
|
185
190
|
```
|
|
186
191
|
|
|
@@ -314,6 +319,14 @@ conn = sqlite3.connect(...) # suppressed — always targets the nex
|
|
|
314
319
|
`pyvibe:` is case-insensitive. A directive with no recognizable `PYVIBE-XXX` id is ignored
|
|
315
320
|
(treated as a regular comment).
|
|
316
321
|
|
|
322
|
+
Add an optional justification after `--` — it's free text, stored and shown in `pyvibe audit`:
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
conn = sqlite3.connect(...) # pyvibe: ignore PYVIBE-008 -- legacy sqlite wrapper
|
|
326
|
+
# pyvibe: ignore-next-line PYVIBE-008 -- startup only
|
|
327
|
+
conn = sqlite3.connect(...)
|
|
328
|
+
```
|
|
329
|
+
|
|
317
330
|
For project-wide rules, add a `pyvibe.toml` next to your code (python-vibe-guard walks up
|
|
318
331
|
from the scan target to find it, same convention as `pyproject.toml`):
|
|
319
332
|
|
|
@@ -346,6 +359,44 @@ Add `--verbose` to see exactly what was suppressed and why:
|
|
|
346
359
|
PYVIBE-019 legacy.py:81 (config)
|
|
347
360
|
```
|
|
348
361
|
|
|
362
|
+
### `pyvibe audit`
|
|
363
|
+
|
|
364
|
+
Audits every inline `# pyvibe: ignore` comment in a codebase: how many carry a justification,
|
|
365
|
+
and how many are **orphaned** — the rule they name never actually fires on the target line,
|
|
366
|
+
usually because the underlying code changed since the suppression was added.
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
$ pyvibe audit src/
|
|
370
|
+
|
|
371
|
+
Suppressions audit
|
|
372
|
+
──────────────────
|
|
373
|
+
Total suppressions: 12
|
|
374
|
+
With justification: 9 (75%)
|
|
375
|
+
Without justification: 3
|
|
376
|
+
Unused (no violation found): 2
|
|
377
|
+
|
|
378
|
+
By rule:
|
|
379
|
+
PYVIBE-008 5
|
|
380
|
+
PYVIBE-019 4
|
|
381
|
+
PYVIBE-003 3
|
|
382
|
+
|
|
383
|
+
Unused suppressions:
|
|
384
|
+
app/legacy.py:41 # pyvibe: ignore PYVIBE-008
|
|
385
|
+
|
|
386
|
+
Without justification:
|
|
387
|
+
models/db.py:88 # pyvibe: ignore PYVIBE-019
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
- `--json` — machine-readable output (`total`, `with_justification`, `without_justification`,
|
|
391
|
+
`unused`, `by_rule`, `unused_suppressions`, `without_justification_suppressions`).
|
|
392
|
+
- `--fail-on-unused` — exit `1` if any orphaned suppression is found.
|
|
393
|
+
- `--fail-on-unjustified` — exit `1` if any suppression is missing a justification.
|
|
394
|
+
- `--max-unused N` — exit `1` if more than `N` orphaned suppressions are found.
|
|
395
|
+
- `--exclude DIR` — same directory-exclusion convention as `pyvibe scan`.
|
|
396
|
+
|
|
397
|
+
With no flags, `pyvibe audit` is purely informational (exit `0`) — the flags above are what
|
|
398
|
+
you wire into CI to enforce a justification/cleanup policy over time.
|
|
399
|
+
|
|
349
400
|
---
|
|
350
401
|
|
|
351
402
|
## CI/CD integration
|
|
@@ -443,7 +494,7 @@ python -m pytest tests/ -v
|
|
|
443
494
|
python tests/test_rules.py
|
|
444
495
|
```
|
|
445
496
|
|
|
446
|
-
|
|
497
|
+
331 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, baseline mode, suppressions (inline comments + pyvibe.toml), and `pyvibe audit` (justifications + orphan detection) coverage.
|
|
447
498
|
|
|
448
499
|
---
|
|
449
500
|
|
{python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/SOURCES.txt
RENAMED
|
@@ -9,6 +9,7 @@ python_vibe_guard.egg-info/top_level.txt
|
|
|
9
9
|
pyvibe/__init__.py
|
|
10
10
|
pyvibe/__main__.py
|
|
11
11
|
pyvibe/analyzer.py
|
|
12
|
+
pyvibe/audit.py
|
|
12
13
|
pyvibe/autofix.py
|
|
13
14
|
pyvibe/baseline.py
|
|
14
15
|
pyvibe/cli.py
|
|
@@ -40,6 +41,7 @@ pyvibe/rules/sqlite_async.py
|
|
|
40
41
|
pyvibe/rules/subprocess_async.py
|
|
41
42
|
pyvibe/rules/threading_lock.py
|
|
42
43
|
pyvibe/rules/while_true_no_await.py
|
|
44
|
+
tests/test_audit.py
|
|
43
45
|
tests/test_baseline.py
|
|
44
46
|
tests/test_config.py
|
|
45
47
|
tests/test_diff.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.12.1"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Suppressions audit — `pyvibe audit`.
|
|
2
|
+
|
|
3
|
+
Cross-references every `# pyvibe: ignore ...` directive found in a file
|
|
4
|
+
against that file's actual violations to answer three questions per
|
|
5
|
+
suppression: does it carry a justification, is it actually suppressing a
|
|
6
|
+
real violation (or is it "orphaned" — no violation of that rule ever
|
|
7
|
+
occurs on the target line), and how do these break down project-wide.
|
|
8
|
+
"""
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from pyvibe.analyzer import DEFAULT_EXCLUDES, _walk, analyze_source_full
|
|
14
|
+
from pyvibe.config import PyvibeConfig, is_excluded
|
|
15
|
+
from pyvibe.suppressions import parse_inline_suppression_directives
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SuppressionRecord:
|
|
20
|
+
"""One inline suppression, exploded per rule_id (a directive naming
|
|
21
|
+
multiple rule IDs — `# pyvibe: ignore PYVIBE-008, PYVIBE-019` — yields
|
|
22
|
+
one record per rule ID, all sharing the same lines/justification).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
filepath: str
|
|
26
|
+
comment_line: int
|
|
27
|
+
target_line: int
|
|
28
|
+
directive: str
|
|
29
|
+
rule_id: str
|
|
30
|
+
justification: Optional[str]
|
|
31
|
+
used: bool # False == orphaned: no violation of rule_id found on target_line
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def audit_file(path: Path, config: Optional[PyvibeConfig] = None) -> List[SuppressionRecord]:
|
|
35
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
36
|
+
_, suppressed = analyze_source_full(source, filepath=str(path), config=config)
|
|
37
|
+
used_keys = {(v.line, v.rule_id) for v, reason in suppressed if reason == "inline"}
|
|
38
|
+
|
|
39
|
+
records = []
|
|
40
|
+
for directive in parse_inline_suppression_directives(source):
|
|
41
|
+
for rule_id in sorted(directive.rule_ids):
|
|
42
|
+
records.append(
|
|
43
|
+
SuppressionRecord(
|
|
44
|
+
filepath=str(path),
|
|
45
|
+
comment_line=directive.comment_line,
|
|
46
|
+
target_line=directive.target_line,
|
|
47
|
+
directive=directive.directive,
|
|
48
|
+
rule_id=rule_id,
|
|
49
|
+
justification=directive.justification,
|
|
50
|
+
used=(directive.target_line, rule_id) in used_keys,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return records
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def audit_directory(
|
|
57
|
+
root: Path,
|
|
58
|
+
exclude: frozenset = DEFAULT_EXCLUDES,
|
|
59
|
+
*,
|
|
60
|
+
config: Optional[PyvibeConfig] = None,
|
|
61
|
+
) -> List[SuppressionRecord]:
|
|
62
|
+
records: List[SuppressionRecord] = []
|
|
63
|
+
for py_file in _walk(root, exclude):
|
|
64
|
+
if config and is_excluded(py_file, config):
|
|
65
|
+
continue
|
|
66
|
+
records.extend(audit_file(py_file, config=config))
|
|
67
|
+
return records
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def audit_path(
|
|
71
|
+
target: Path,
|
|
72
|
+
exclude: frozenset = DEFAULT_EXCLUDES,
|
|
73
|
+
*,
|
|
74
|
+
config: Optional[PyvibeConfig] = None,
|
|
75
|
+
) -> List[SuppressionRecord]:
|
|
76
|
+
"""audit_file()/audit_directory(), dispatching on whether target is a
|
|
77
|
+
file or a directory — same convention as analyzer.analyze_file/_directory.
|
|
78
|
+
"""
|
|
79
|
+
if target.is_file():
|
|
80
|
+
if target.suffix != ".py":
|
|
81
|
+
return []
|
|
82
|
+
if config and is_excluded(target, config):
|
|
83
|
+
return []
|
|
84
|
+
return audit_file(target, config=config)
|
|
85
|
+
return audit_directory(target, exclude=exclude, config=config)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class AuditSummary:
|
|
90
|
+
total: int
|
|
91
|
+
with_justification: int
|
|
92
|
+
without_justification: int
|
|
93
|
+
unused: int
|
|
94
|
+
by_rule: Dict[str, int]
|
|
95
|
+
unused_records: List[SuppressionRecord]
|
|
96
|
+
unjustified_records: List[SuppressionRecord]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def summarize(records: List[SuppressionRecord]) -> AuditSummary:
|
|
100
|
+
total = len(records)
|
|
101
|
+
with_justification = sum(1 for r in records if r.justification)
|
|
102
|
+
unjustified_records = [r for r in records if not r.justification]
|
|
103
|
+
unused_records = [r for r in records if not r.used]
|
|
104
|
+
|
|
105
|
+
by_rule: Dict[str, int] = {}
|
|
106
|
+
for r in records:
|
|
107
|
+
by_rule[r.rule_id] = by_rule.get(r.rule_id, 0) + 1
|
|
108
|
+
# Most-suppressed rule first; rule_id ascending breaks ties.
|
|
109
|
+
by_rule = dict(sorted(by_rule.items(), key=lambda kv: (-kv[1], kv[0])))
|
|
110
|
+
|
|
111
|
+
return AuditSummary(
|
|
112
|
+
total=total,
|
|
113
|
+
with_justification=with_justification,
|
|
114
|
+
without_justification=len(unjustified_records),
|
|
115
|
+
unused=len(unused_records),
|
|
116
|
+
by_rule=by_rule,
|
|
117
|
+
unused_records=unused_records,
|
|
118
|
+
unjustified_records=unjustified_records,
|
|
119
|
+
)
|
|
@@ -16,11 +16,13 @@ Usage:
|
|
|
16
16
|
python -m pyvibe <path> --baseline # scan, suppressing findings already in the baseline
|
|
17
17
|
python -m pyvibe scan <path> --baseline # equivalent, explicit subcommand form
|
|
18
18
|
python -m pyvibe <path> --verbose # also list suppressed findings (inline / pyvibe.toml)
|
|
19
|
+
python -m pyvibe audit [path] # audit inline suppressions: justification + orphan usage
|
|
19
20
|
|
|
20
21
|
Suppressing findings:
|
|
21
22
|
# pyvibe: ignore PYVIBE-008 (same line, or the next non-comment line if standalone)
|
|
22
23
|
# pyvibe: ignore PYVIBE-008, PYVIBE-003 (multiple rules)
|
|
23
24
|
# pyvibe: ignore-next-line PYVIBE-008 (always the next line)
|
|
25
|
+
# pyvibe: ignore PYVIBE-008 -- reason (optional justification, shown in `pyvibe audit`)
|
|
24
26
|
See pyvibe.toml for project-wide ignore / exclude / severity overrides.
|
|
25
27
|
"""
|
|
26
28
|
import argparse
|
|
@@ -61,6 +63,10 @@ def main():
|
|
|
61
63
|
_main_scan(sys.argv[2:])
|
|
62
64
|
return
|
|
63
65
|
|
|
66
|
+
if len(sys.argv) > 1 and sys.argv[1] == "audit":
|
|
67
|
+
_main_audit(sys.argv[2:])
|
|
68
|
+
return
|
|
69
|
+
|
|
64
70
|
parser = argparse.ArgumentParser(
|
|
65
71
|
prog="pyvibe",
|
|
66
72
|
description="Detect runtime anti-patterns in async Python code",
|
|
@@ -518,6 +524,134 @@ def _main_baseline(argv):
|
|
|
518
524
|
sys.exit(0)
|
|
519
525
|
|
|
520
526
|
|
|
527
|
+
def _main_audit(argv):
|
|
528
|
+
parser = argparse.ArgumentParser(
|
|
529
|
+
prog="pyvibe audit",
|
|
530
|
+
description="Audit inline `# pyvibe: ignore` suppressions: justification coverage + orphaned (unused) suppressions",
|
|
531
|
+
)
|
|
532
|
+
parser.add_argument("path", nargs="?", default=".", help="File or directory to audit (default: current directory)")
|
|
533
|
+
parser.add_argument(
|
|
534
|
+
"--exclude",
|
|
535
|
+
metavar="DIR",
|
|
536
|
+
action="append",
|
|
537
|
+
default=[],
|
|
538
|
+
help="Directory name to exclude (can be repeated)",
|
|
539
|
+
)
|
|
540
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
541
|
+
parser.add_argument(
|
|
542
|
+
"--fail-on-unused",
|
|
543
|
+
action="store_true",
|
|
544
|
+
help="Exit 1 if any orphaned suppression is found (no violation on its target line)",
|
|
545
|
+
)
|
|
546
|
+
parser.add_argument(
|
|
547
|
+
"--fail-on-unjustified",
|
|
548
|
+
action="store_true",
|
|
549
|
+
help="Exit 1 if any suppression is missing a `-- justification`",
|
|
550
|
+
)
|
|
551
|
+
parser.add_argument(
|
|
552
|
+
"--max-unused",
|
|
553
|
+
metavar="N",
|
|
554
|
+
type=int,
|
|
555
|
+
default=None,
|
|
556
|
+
help="Exit 1 if more than N orphaned suppressions are found",
|
|
557
|
+
)
|
|
558
|
+
args = parser.parse_args(argv)
|
|
559
|
+
|
|
560
|
+
from pyvibe.audit import audit_path, summarize
|
|
561
|
+
|
|
562
|
+
target = Path(args.path)
|
|
563
|
+
if not target.exists():
|
|
564
|
+
print(f"Error: {target} does not exist", file=sys.stderr)
|
|
565
|
+
sys.exit(2)
|
|
566
|
+
|
|
567
|
+
config = _load_config_or_exit(target)
|
|
568
|
+
exclude = DEFAULT_EXCLUDES | frozenset(args.exclude)
|
|
569
|
+
|
|
570
|
+
records = audit_path(target, exclude=exclude, config=config)
|
|
571
|
+
summary = summarize(records)
|
|
572
|
+
|
|
573
|
+
if args.json:
|
|
574
|
+
_print_audit_json(summary)
|
|
575
|
+
else:
|
|
576
|
+
_print_audit_human(summary)
|
|
577
|
+
|
|
578
|
+
failed = (
|
|
579
|
+
(args.fail_on_unused and summary.unused > 0)
|
|
580
|
+
or (args.fail_on_unjustified and summary.without_justification > 0)
|
|
581
|
+
or (args.max_unused is not None and summary.unused > args.max_unused)
|
|
582
|
+
)
|
|
583
|
+
sys.exit(1 if failed else 0)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _format_directive(record) -> str:
|
|
587
|
+
return f"# pyvibe: {record.directive} {record.rule_id}"
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _print_audit_human(summary):
|
|
591
|
+
print()
|
|
592
|
+
title = "Suppressions audit"
|
|
593
|
+
print(title)
|
|
594
|
+
print("─" * len(title))
|
|
595
|
+
|
|
596
|
+
if summary.total == 0:
|
|
597
|
+
print("No suppressions found.")
|
|
598
|
+
print()
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
pct = round(summary.with_justification / summary.total * 100)
|
|
602
|
+
print(f"Total suppressions: {summary.total}")
|
|
603
|
+
print(f"With justification: {summary.with_justification} ({pct}%)")
|
|
604
|
+
print(f"Without justification: {summary.without_justification}")
|
|
605
|
+
print(f"Unused (no violation found): {summary.unused}")
|
|
606
|
+
print()
|
|
607
|
+
|
|
608
|
+
print("By rule:")
|
|
609
|
+
for rule_id, count in summary.by_rule.items():
|
|
610
|
+
print(f"{rule_id:<12} {count}")
|
|
611
|
+
print()
|
|
612
|
+
|
|
613
|
+
if summary.unused_records:
|
|
614
|
+
print("Unused suppressions:")
|
|
615
|
+
for r in summary.unused_records:
|
|
616
|
+
print(f"{r.filepath}:{r.comment_line} {_format_directive(r)}")
|
|
617
|
+
print()
|
|
618
|
+
|
|
619
|
+
if summary.unjustified_records:
|
|
620
|
+
print("Without justification:")
|
|
621
|
+
for r in summary.unjustified_records:
|
|
622
|
+
print(f"{r.filepath}:{r.comment_line} {_format_directive(r)}")
|
|
623
|
+
print()
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _print_audit_json(summary):
|
|
627
|
+
output = {
|
|
628
|
+
"total": summary.total,
|
|
629
|
+
"with_justification": summary.with_justification,
|
|
630
|
+
"without_justification": summary.without_justification,
|
|
631
|
+
"unused": summary.unused,
|
|
632
|
+
"by_rule": summary.by_rule,
|
|
633
|
+
"unused_suppressions": [
|
|
634
|
+
{
|
|
635
|
+
"file": r.filepath,
|
|
636
|
+
"line": r.comment_line,
|
|
637
|
+
"rule_id": r.rule_id,
|
|
638
|
+
"justification": r.justification,
|
|
639
|
+
}
|
|
640
|
+
for r in summary.unused_records
|
|
641
|
+
],
|
|
642
|
+
"without_justification_suppressions": [
|
|
643
|
+
{
|
|
644
|
+
"file": r.filepath,
|
|
645
|
+
"line": r.comment_line,
|
|
646
|
+
"rule_id": r.rule_id,
|
|
647
|
+
"justification": r.justification,
|
|
648
|
+
}
|
|
649
|
+
for r in summary.unjustified_records
|
|
650
|
+
],
|
|
651
|
+
}
|
|
652
|
+
print(json.dumps(output, indent=2))
|
|
653
|
+
|
|
654
|
+
|
|
521
655
|
def _main_explain(argv):
|
|
522
656
|
parser = argparse.ArgumentParser(
|
|
523
657
|
prog="pyvibe explain",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Inline suppression comments — `# pyvibe: ignore PYVIBE-XXX[, PYVIBE-YYY]`
|
|
2
|
+
and `# pyvibe: ignore-next-line PYVIBE-XXX`, optionally followed by
|
|
3
|
+
`-- justification text`:
|
|
4
|
+
|
|
5
|
+
# pyvibe: ignore PYVIBE-008 -- legacy sqlite wrapper
|
|
6
|
+
# pyvibe: ignore-next-line PYVIBE-008 -- startup only
|
|
7
|
+
|
|
8
|
+
Parsed directly from the source text being analyzed (no filesystem I/O),
|
|
9
|
+
so — unlike pyvibe.toml (see pyvibe/config.py) — this is always active with
|
|
10
|
+
no opt-in required.
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Dict, FrozenSet, List, Optional, Set
|
|
15
|
+
|
|
16
|
+
_DIRECTIVE_RE = re.compile(
|
|
17
|
+
r"#\s*pyvibe\s*:\s*(?P<directive>ignore-next-line|ignore)\b(?P<rest>[^\n]*)",
|
|
18
|
+
re.IGNORECASE,
|
|
19
|
+
)
|
|
20
|
+
_RULE_ID_RE = re.compile(r"PYVIBE-\d{3}", re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SuppressionDirective:
|
|
25
|
+
"""A single parsed `# pyvibe: ignore ...` comment.
|
|
26
|
+
|
|
27
|
+
comment_line: the physical line the comment itself is written on.
|
|
28
|
+
target_line: the line whose violations it suppresses (see module-level
|
|
29
|
+
ignore/ignore-next-line semantics below).
|
|
30
|
+
directive: "ignore" or "ignore-next-line", as written.
|
|
31
|
+
rule_ids: the rule IDs named in the directive (never empty).
|
|
32
|
+
justification: free text after `--`, or None if not given.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
comment_line: int
|
|
36
|
+
target_line: int
|
|
37
|
+
directive: str
|
|
38
|
+
rule_ids: FrozenSet[str]
|
|
39
|
+
justification: Optional[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_inline_suppression_directives(source: str) -> List[SuppressionDirective]:
|
|
43
|
+
"""Returns every `# pyvibe: ignore[-next-line] ...` directive found in
|
|
44
|
+
`source`, in file order.
|
|
45
|
+
|
|
46
|
+
A directive comment is "standalone" if nothing but whitespace precedes
|
|
47
|
+
the `#` on its physical line; otherwise it's "trailing" on a code line.
|
|
48
|
+
|
|
49
|
+
- `ignore-next-line` always targets the line right after the comment.
|
|
50
|
+
- `ignore` targets its OWN line when trailing on code (the common case:
|
|
51
|
+
`conn = sqlite3.connect(...) # pyvibe: ignore PYVIBE-008`), or the
|
|
52
|
+
FOLLOWING line when standalone — so a bare `# pyvibe: ignore X` on its
|
|
53
|
+
own line directly above the flagged code also works, same as
|
|
54
|
+
`ignore-next-line`.
|
|
55
|
+
"""
|
|
56
|
+
directives: List[SuppressionDirective] = []
|
|
57
|
+
|
|
58
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
59
|
+
match = _DIRECTIVE_RE.search(line)
|
|
60
|
+
if not match:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
rest = match.group("rest")
|
|
64
|
+
if "--" in rest:
|
|
65
|
+
rule_part, justification_part = rest.split("--", 1)
|
|
66
|
+
justification = justification_part.strip() or None
|
|
67
|
+
else:
|
|
68
|
+
rule_part, justification = rest, None
|
|
69
|
+
|
|
70
|
+
rule_ids = frozenset(rid.upper() for rid in _RULE_ID_RE.findall(rule_part))
|
|
71
|
+
if not rule_ids:
|
|
72
|
+
continue # directive with no recognizable rule ID — not our comment
|
|
73
|
+
|
|
74
|
+
directive = match.group("directive").lower()
|
|
75
|
+
is_standalone = line[: match.start()].strip() == ""
|
|
76
|
+
|
|
77
|
+
target_line = lineno + 1 if (directive == "ignore-next-line" or is_standalone) else lineno
|
|
78
|
+
|
|
79
|
+
directives.append(
|
|
80
|
+
SuppressionDirective(
|
|
81
|
+
comment_line=lineno,
|
|
82
|
+
target_line=target_line,
|
|
83
|
+
directive=directive,
|
|
84
|
+
rule_ids=rule_ids,
|
|
85
|
+
justification=justification,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return directives
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_inline_suppressions(source: str) -> Dict[int, FrozenSet[str]]:
|
|
93
|
+
"""Returns {line_number: {rule_id, ...}} — 1-indexed line numbers whose
|
|
94
|
+
violations should be suppressed. See parse_inline_suppression_directives()
|
|
95
|
+
for the full per-directive detail (justification, comment line, etc.).
|
|
96
|
+
"""
|
|
97
|
+
suppressions: Dict[int, Set[str]] = {}
|
|
98
|
+
|
|
99
|
+
for directive in parse_inline_suppression_directives(source):
|
|
100
|
+
suppressions.setdefault(directive.target_line, set()).update(directive.rule_ids)
|
|
101
|
+
|
|
102
|
+
return {line: frozenset(ids) for line, ids in suppressions.items()}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Tests for v0.12b: suppression justifications + `pyvibe audit`
|
|
2
|
+
(pyvibe/suppressions.py's justification parsing, pyvibe/audit.py, and the
|
|
3
|
+
`pyvibe audit` CLI subcommand)."""
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
11
|
+
|
|
12
|
+
from pyvibe.audit import audit_directory, audit_file, summarize
|
|
13
|
+
from pyvibe.suppressions import parse_inline_suppression_directives, parse_inline_suppressions
|
|
14
|
+
|
|
15
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ─── justification parsing (parse_inline_suppression_directives) ───────────
|
|
19
|
+
|
|
20
|
+
def test_trailing_directive_with_justification():
|
|
21
|
+
src = 'conn = sqlite3.connect("x") # pyvibe: ignore PYVIBE-008 -- legacy sqlite wrapper\n'
|
|
22
|
+
directives = parse_inline_suppression_directives(src)
|
|
23
|
+
assert len(directives) == 1
|
|
24
|
+
d = directives[0]
|
|
25
|
+
assert d.rule_ids == frozenset({"PYVIBE-008"})
|
|
26
|
+
assert d.justification == "legacy sqlite wrapper"
|
|
27
|
+
assert d.comment_line == 1
|
|
28
|
+
assert d.target_line == 1
|
|
29
|
+
assert d.directive == "ignore"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_ignore_next_line_with_justification():
|
|
33
|
+
src = "# pyvibe: ignore-next-line PYVIBE-008 -- startup only\nconn = sqlite3.connect(\"x\")\n"
|
|
34
|
+
directives = parse_inline_suppression_directives(src)
|
|
35
|
+
assert len(directives) == 1
|
|
36
|
+
d = directives[0]
|
|
37
|
+
assert d.justification == "startup only"
|
|
38
|
+
assert d.comment_line == 1
|
|
39
|
+
assert d.target_line == 2
|
|
40
|
+
assert d.directive == "ignore-next-line"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_directive_without_justification_is_none():
|
|
44
|
+
src = 'conn = sqlite3.connect("x") # pyvibe: ignore PYVIBE-008\n'
|
|
45
|
+
d = parse_inline_suppression_directives(src)[0]
|
|
46
|
+
assert d.justification is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_justification_is_stripped_of_whitespace():
|
|
50
|
+
src = 'conn = sqlite3.connect("x") # pyvibe: ignore PYVIBE-008 -- padded reason \n'
|
|
51
|
+
d = parse_inline_suppression_directives(src)[0]
|
|
52
|
+
assert d.justification == "padded reason"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_empty_justification_after_dashes_is_none():
|
|
56
|
+
src = 'conn = sqlite3.connect("x") # pyvibe: ignore PYVIBE-008 --\n'
|
|
57
|
+
d = parse_inline_suppression_directives(src)[0]
|
|
58
|
+
assert d.justification is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_multiple_rule_ids_share_one_justification():
|
|
62
|
+
src = 'time.sleep(1) # pyvibe: ignore PYVIBE-001, PYVIBE-003 -- benchmarked delay\n'
|
|
63
|
+
d = parse_inline_suppression_directives(src)[0]
|
|
64
|
+
assert d.rule_ids == frozenset({"PYVIBE-001", "PYVIBE-003"})
|
|
65
|
+
assert d.justification == "benchmarked delay"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_parse_inline_suppressions_still_ignores_justification_text():
|
|
69
|
+
# backward-compat: the simpler dict API used by analyzer.py doesn't
|
|
70
|
+
# expose justifications, only {line: {rule_ids}}.
|
|
71
|
+
src = 'conn = sqlite3.connect("x") # pyvibe: ignore PYVIBE-008 -- legacy\n'
|
|
72
|
+
assert parse_inline_suppressions(src) == {1: frozenset({"PYVIBE-008"})}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ─── orphan / unused suppression detection (pyvibe/audit.py) ───────────────
|
|
76
|
+
|
|
77
|
+
USED_AND_ORPHAN_SRC = """\
|
|
78
|
+
import sqlite3
|
|
79
|
+
import time
|
|
80
|
+
|
|
81
|
+
async def handler():
|
|
82
|
+
conn = sqlite3.connect("db.sqlite") # pyvibe: ignore PYVIBE-008 -- legacy sqlite wrapper
|
|
83
|
+
return conn
|
|
84
|
+
|
|
85
|
+
def unrelated():
|
|
86
|
+
x = 1 # pyvibe: ignore PYVIBE-019
|
|
87
|
+
return x
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_audit_file_marks_used_suppression(tmp_path):
|
|
92
|
+
f = tmp_path / "app.py"
|
|
93
|
+
f.write_text(USED_AND_ORPHAN_SRC)
|
|
94
|
+
|
|
95
|
+
records = audit_file(f)
|
|
96
|
+
used = [r for r in records if r.rule_id == "PYVIBE-008"]
|
|
97
|
+
assert len(used) == 1
|
|
98
|
+
assert used[0].used is True
|
|
99
|
+
assert used[0].justification == "legacy sqlite wrapper"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_audit_file_marks_orphan_suppression(tmp_path):
|
|
103
|
+
f = tmp_path / "app.py"
|
|
104
|
+
f.write_text(USED_AND_ORPHAN_SRC)
|
|
105
|
+
|
|
106
|
+
records = audit_file(f)
|
|
107
|
+
orphan = [r for r in records if r.rule_id == "PYVIBE-019"]
|
|
108
|
+
assert len(orphan) == 1
|
|
109
|
+
assert orphan[0].used is False
|
|
110
|
+
assert orphan[0].justification is None
|
|
111
|
+
assert orphan[0].comment_line == 9
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_audit_directory_aggregates_across_files(tmp_path):
|
|
115
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
116
|
+
(tmp_path / "clean.py").write_text("x = 1\n")
|
|
117
|
+
|
|
118
|
+
records = audit_directory(tmp_path)
|
|
119
|
+
assert len(records) == 2
|
|
120
|
+
assert {r.rule_id for r in records} == {"PYVIBE-008", "PYVIBE-019"}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ─── summarize() ────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def test_summarize_counts_and_by_rule(tmp_path):
|
|
126
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
127
|
+
records = audit_directory(tmp_path)
|
|
128
|
+
|
|
129
|
+
summary = summarize(records)
|
|
130
|
+
|
|
131
|
+
assert summary.total == 2
|
|
132
|
+
assert summary.with_justification == 1
|
|
133
|
+
assert summary.without_justification == 1
|
|
134
|
+
assert summary.unused == 1
|
|
135
|
+
assert summary.by_rule == {"PYVIBE-008": 1, "PYVIBE-019": 1}
|
|
136
|
+
assert [r.rule_id for r in summary.unused_records] == ["PYVIBE-019"]
|
|
137
|
+
assert [r.rule_id for r in summary.unjustified_records] == ["PYVIBE-019"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_summarize_empty_records():
|
|
141
|
+
summary = summarize([])
|
|
142
|
+
assert summary.total == 0
|
|
143
|
+
assert summary.with_justification == 0
|
|
144
|
+
assert summary.without_justification == 0
|
|
145
|
+
assert summary.unused == 0
|
|
146
|
+
assert summary.by_rule == {}
|
|
147
|
+
assert summary.unused_records == []
|
|
148
|
+
assert summary.unjustified_records == []
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_summarize_by_rule_sorted_by_count_desc():
|
|
152
|
+
from pyvibe.audit import SuppressionRecord
|
|
153
|
+
|
|
154
|
+
records = [
|
|
155
|
+
SuppressionRecord("f.py", 1, 1, "ignore", "PYVIBE-008", None, True),
|
|
156
|
+
SuppressionRecord("f.py", 1, 1, "ignore", "PYVIBE-008", None, True),
|
|
157
|
+
SuppressionRecord("f.py", 2, 2, "ignore", "PYVIBE-019", None, True),
|
|
158
|
+
SuppressionRecord("f.py", 2, 2, "ignore", "PYVIBE-019", None, True),
|
|
159
|
+
SuppressionRecord("f.py", 3, 3, "ignore", "PYVIBE-019", None, True),
|
|
160
|
+
SuppressionRecord("f.py", 4, 4, "ignore", "PYVIBE-003", None, True),
|
|
161
|
+
]
|
|
162
|
+
summary = summarize(records)
|
|
163
|
+
assert list(summary.by_rule.items()) == [
|
|
164
|
+
("PYVIBE-019", 3),
|
|
165
|
+
("PYVIBE-008", 2),
|
|
166
|
+
("PYVIBE-003", 1),
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ─── `pyvibe audit` CLI (subprocess, human + --json + exit codes) ─────────
|
|
171
|
+
|
|
172
|
+
def _run_cli(args, cwd):
|
|
173
|
+
env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)}
|
|
174
|
+
return subprocess.run(
|
|
175
|
+
[sys.executable, "-m", "pyvibe", *args],
|
|
176
|
+
cwd=cwd,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True,
|
|
179
|
+
env=env,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_cli_audit_human_output(tmp_path):
|
|
184
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
185
|
+
|
|
186
|
+
result = _run_cli(["audit", "."], cwd=tmp_path)
|
|
187
|
+
|
|
188
|
+
assert result.returncode == 0
|
|
189
|
+
assert "Suppressions audit" in result.stdout
|
|
190
|
+
assert "Total suppressions: 2" in result.stdout
|
|
191
|
+
assert "With justification: 1 (50%)" in result.stdout
|
|
192
|
+
assert "Without justification: 1" in result.stdout
|
|
193
|
+
assert "Unused (no violation found): 1" in result.stdout
|
|
194
|
+
assert "By rule:" in result.stdout
|
|
195
|
+
assert "PYVIBE-008" in result.stdout
|
|
196
|
+
assert "Unused suppressions:" in result.stdout
|
|
197
|
+
assert "app.py:9" in result.stdout
|
|
198
|
+
assert "Without justification:" in result.stdout
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_cli_audit_no_suppressions(tmp_path):
|
|
202
|
+
(tmp_path / "clean.py").write_text("x = 1\n")
|
|
203
|
+
|
|
204
|
+
result = _run_cli(["audit", "."], cwd=tmp_path)
|
|
205
|
+
|
|
206
|
+
assert result.returncode == 0
|
|
207
|
+
assert "No suppressions found." in result.stdout
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_cli_audit_json_output(tmp_path):
|
|
211
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
212
|
+
|
|
213
|
+
result = _run_cli(["audit", ".", "--json"], cwd=tmp_path)
|
|
214
|
+
|
|
215
|
+
assert result.returncode == 0
|
|
216
|
+
data = json.loads(result.stdout)
|
|
217
|
+
assert data["total"] == 2
|
|
218
|
+
assert data["with_justification"] == 1
|
|
219
|
+
assert data["without_justification"] == 1
|
|
220
|
+
assert data["unused"] == 1
|
|
221
|
+
assert data["by_rule"] == {"PYVIBE-008": 1, "PYVIBE-019": 1}
|
|
222
|
+
assert len(data["unused_suppressions"]) == 1
|
|
223
|
+
assert data["unused_suppressions"][0]["rule_id"] == "PYVIBE-019"
|
|
224
|
+
assert data["unused_suppressions"][0]["line"] == 9
|
|
225
|
+
assert len(data["without_justification_suppressions"]) == 1
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_cli_audit_fail_on_unused_exits_1(tmp_path):
|
|
229
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
230
|
+
|
|
231
|
+
result = _run_cli(["audit", ".", "--fail-on-unused"], cwd=tmp_path)
|
|
232
|
+
|
|
233
|
+
assert result.returncode == 1
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_cli_audit_fail_on_unjustified_exits_1(tmp_path):
|
|
237
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
238
|
+
|
|
239
|
+
result = _run_cli(["audit", ".", "--fail-on-unjustified"], cwd=tmp_path)
|
|
240
|
+
|
|
241
|
+
assert result.returncode == 1
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_cli_audit_max_unused_triggers_failure(tmp_path):
|
|
245
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
246
|
+
|
|
247
|
+
result = _run_cli(["audit", ".", "--max-unused", "0"], cwd=tmp_path)
|
|
248
|
+
assert result.returncode == 1
|
|
249
|
+
|
|
250
|
+
result = _run_cli(["audit", ".", "--max-unused", "5"], cwd=tmp_path)
|
|
251
|
+
assert result.returncode == 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_cli_audit_no_flags_exits_0_even_with_unused(tmp_path):
|
|
255
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
256
|
+
|
|
257
|
+
result = _run_cli(["audit", "."], cwd=tmp_path)
|
|
258
|
+
|
|
259
|
+
assert result.returncode == 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_cli_audit_defaults_to_current_directory(tmp_path):
|
|
263
|
+
(tmp_path / "app.py").write_text(USED_AND_ORPHAN_SRC)
|
|
264
|
+
|
|
265
|
+
result = _run_cli(["audit"], cwd=tmp_path)
|
|
266
|
+
|
|
267
|
+
assert result.returncode == 0
|
|
268
|
+
assert "Total suppressions: 2" in result.stdout
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_cli_audit_missing_path_errors(tmp_path):
|
|
272
|
+
result = _run_cli(["audit", "does-not-exist"], cwd=tmp_path)
|
|
273
|
+
assert result.returncode == 2
|
|
274
|
+
assert "does not exist" in result.stderr
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.12.0"
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
"""Inline suppression comments — `# pyvibe: ignore PYVIBE-XXX[, PYVIBE-YYY]`
|
|
2
|
-
and `# pyvibe: ignore-next-line PYVIBE-XXX`.
|
|
3
|
-
|
|
4
|
-
Parsed directly from the source text being analyzed (no filesystem I/O),
|
|
5
|
-
so — unlike pyvibe.toml (see pyvibe/config.py) — this is always active with
|
|
6
|
-
no opt-in required.
|
|
7
|
-
"""
|
|
8
|
-
import re
|
|
9
|
-
from typing import Dict, FrozenSet, Set
|
|
10
|
-
|
|
11
|
-
_DIRECTIVE_RE = re.compile(
|
|
12
|
-
r"#\s*pyvibe\s*:\s*(?P<directive>ignore-next-line|ignore)\b(?P<rule_ids>[^\n]*)",
|
|
13
|
-
re.IGNORECASE,
|
|
14
|
-
)
|
|
15
|
-
_RULE_ID_RE = re.compile(r"PYVIBE-\d{3}", re.IGNORECASE)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def parse_inline_suppressions(source: str) -> Dict[int, FrozenSet[str]]:
|
|
19
|
-
"""Returns {line_number: {rule_id, ...}} — 1-indexed line numbers whose
|
|
20
|
-
violations should be suppressed.
|
|
21
|
-
|
|
22
|
-
A directive comment is "standalone" if nothing but whitespace precedes
|
|
23
|
-
the `#` on its physical line; otherwise it's "trailing" on a code line.
|
|
24
|
-
|
|
25
|
-
- `ignore-next-line` always targets the line right after the comment.
|
|
26
|
-
- `ignore` targets its OWN line when trailing on code (the common case:
|
|
27
|
-
`conn = sqlite3.connect(...) # pyvibe: ignore PYVIBE-008`), or the
|
|
28
|
-
FOLLOWING line when standalone — so a bare `# pyvibe: ignore X` on its
|
|
29
|
-
own line directly above the flagged code also works, same as
|
|
30
|
-
`ignore-next-line`.
|
|
31
|
-
"""
|
|
32
|
-
suppressions: Dict[int, Set[str]] = {}
|
|
33
|
-
|
|
34
|
-
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
35
|
-
match = _DIRECTIVE_RE.search(line)
|
|
36
|
-
if not match:
|
|
37
|
-
continue
|
|
38
|
-
|
|
39
|
-
rule_ids = {rid.upper() for rid in _RULE_ID_RE.findall(match.group("rule_ids"))}
|
|
40
|
-
if not rule_ids:
|
|
41
|
-
continue # directive with no recognizable rule ID — not our comment
|
|
42
|
-
|
|
43
|
-
directive = match.group("directive").lower()
|
|
44
|
-
is_standalone = line[: match.start()].strip() == ""
|
|
45
|
-
|
|
46
|
-
if directive == "ignore-next-line" or is_standalone:
|
|
47
|
-
target_line = lineno + 1
|
|
48
|
-
else:
|
|
49
|
-
target_line = lineno
|
|
50
|
-
|
|
51
|
-
suppressions.setdefault(target_line, set()).update(rule_ids)
|
|
52
|
-
|
|
53
|
-
return {line: frozenset(ids) for line, ids in suppressions.items()}
|
|
File without changes
|
{python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/requires.txt
RENAMED
|
File without changes
|
{python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/gather_no_return_exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/loop_run_until_complete.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|