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.
Files changed (56) hide show
  1. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/PKG-INFO +53 -2
  2. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/README.md +52 -1
  3. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyproject.toml +1 -1
  4. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/PKG-INFO +53 -2
  5. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/SOURCES.txt +2 -0
  6. python_vibe_guard-0.12.1/pyvibe/__init__.py +1 -0
  7. python_vibe_guard-0.12.1/pyvibe/audit.py +119 -0
  8. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/cli.py +134 -0
  9. python_vibe_guard-0.12.1/pyvibe/suppressions.py +102 -0
  10. python_vibe_guard-0.12.1/tests/test_audit.py +274 -0
  11. python_vibe_guard-0.12.0/pyvibe/__init__.py +0 -1
  12. python_vibe_guard-0.12.0/pyvibe/suppressions.py +0 -53
  13. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
  14. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/entry_points.txt +0 -0
  15. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/requires.txt +0 -0
  16. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/python_vibe_guard.egg-info/top_level.txt +0 -0
  17. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/__main__.py +0 -0
  18. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/analyzer.py +0 -0
  19. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/autofix.py +0 -0
  20. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/baseline.py +0 -0
  21. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/config.py +0 -0
  22. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/diff.py +0 -0
  23. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/explain.py +0 -0
  24. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rule_docs.py +0 -0
  25. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/__init__.py +0 -0
  26. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/async_requests.py +0 -0
  27. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/async_sleep.py +0 -0
  28. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/asyncio_run.py +0 -0
  29. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/base.py +0 -0
  30. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/celery_time_limit.py +0 -0
  31. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/contextvar_cleanup.py +0 -0
  32. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/create_task_orphan.py +0 -0
  33. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/ensure_future_orphan.py +0 -0
  34. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
  35. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/httpx_client_sync.py +0 -0
  36. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/httpx_sync.py +0 -0
  37. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/loop_run_until_complete.py +0 -0
  38. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/open_async.py +0 -0
  39. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/os_blocking.py +0 -0
  40. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/queue_put_nowait.py +0 -0
  41. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/retry_no_backoff.py +0 -0
  42. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/silent_except.py +0 -0
  43. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/sqlite_async.py +0 -0
  44. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/subprocess_async.py +0 -0
  45. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/threading_lock.py +0 -0
  46. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/rules/while_true_no_await.py +0 -0
  47. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/pyvibe/sarif.py +0 -0
  48. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/setup.cfg +0 -0
  49. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_baseline.py +0 -0
  50. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_config.py +0 -0
  51. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_diff.py +0 -0
  52. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_exclude.py +0 -0
  53. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_explain.py +0 -0
  54. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_rules.py +0 -0
  55. {python_vibe_guard-0.12.0 → python_vibe_guard-0.12.1}/tests/test_sarif.py +0 -0
  56. {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.0
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
- 309 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, baseline mode, and suppressions (inline comments + pyvibe.toml) coverage.
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
- 309 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, baseline mode, and suppressions (inline comments + pyvibe.toml) coverage.
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.0"
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.0
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
- 309 tests: true positives + false-positive guards for every rule, plus SARIF output, `pyvibe explain`, `pyvibe review`, baseline mode, and suppressions (inline comments + pyvibe.toml) coverage.
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
 
@@ -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()}