sober-audit 2.0.0__py3-none-any.whl

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.
sober/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ sober - The Vibe Coding Hangover Cure.
3
+ Audit, sanitize, and proofread AI-generated code before pushing to production.
4
+ """
5
+
6
+ __version__ = "2.0.0"
7
+ __author__ = "shenald-dev"
sober/cli.py ADDED
@@ -0,0 +1,491 @@
1
+ """
2
+ CLI Entrypoint for SOBER - The Vibe Coding Hangover Cure.
3
+ """
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from sober import __version__
12
+ from sober.core.ci import init_github_actions_workflow
13
+ from sober.core.config import load_config
14
+ from sober.core.git_helper import (
15
+ get_git_diff_range_files,
16
+ get_git_hook_status,
17
+ get_git_hooks_dir,
18
+ install_git_hook,
19
+ uninstall_git_hook,
20
+ )
21
+ from sober.core.manifest import ManifestInspector
22
+ from sober.core.remediation import generate_agent_remediation_prompt
23
+ from sober.core.sanitizer import apply_cure
24
+ from sober.core.scanner import FileToAudit, get_all_code_files, get_git_modified_files
25
+ from sober.core.scorer import SoberAuditReport
26
+ from sober.core.watcher import LiveSoberWatcher
27
+ from sober.detectors.antipatterns import audit_antipatterns
28
+ from sober.detectors.chatter import audit_chatter
29
+ from sober.detectors.duplicates import audit_duplicates
30
+ from sober.detectors.ghost_code import audit_ghost_code
31
+ from sober.detectors.hallucinations import audit_hallucinations
32
+ from sober.detectors.security import audit_security
33
+ from sober.ui.console import (
34
+ console,
35
+ print_banner,
36
+ render_cure_results,
37
+ render_hook_status,
38
+ render_report,
39
+ )
40
+
41
+ app = typer.Typer(
42
+ help="🍸 The Vibe Coding Hangover Cure. Audit, sanitize, and proofread AI-generated code before pushing to production.",
43
+ add_completion=False,
44
+ no_args_is_help=False,
45
+ )
46
+
47
+ hook_app = typer.Typer(help="Manage Git pre-commit hooks for automated sobriety checks.", add_completion=False)
48
+ app.add_typer(hook_app, name="hook")
49
+
50
+ ci_app = typer.Typer(help="Manage CI/CD workflows and automated GitHub Actions gates.", add_completion=False)
51
+ app.add_typer(ci_app, name="ci")
52
+
53
+
54
+ def version_callback(value: bool):
55
+ if value:
56
+ console.print(f"[bold cyan]sober[/] version [bold green]{__version__}[/]")
57
+ raise typer.Exit()
58
+
59
+
60
+ @app.callback(invoke_without_command=True)
61
+ def _main_callback(
62
+ ctx: typer.Context,
63
+ _version: bool | None = typer.Option(
64
+ None, "--version", "-v", help="Show version and exit.", callback=version_callback, is_eager=True
65
+ ),
66
+ ):
67
+ if ctx.invoked_subcommand is None:
68
+ scan(Path("."))
69
+
70
+
71
+ def _execute_audit(
72
+ files: list[FileToAudit],
73
+ path: Path,
74
+ json_output: bool = False,
75
+ sarif_output: bool = False,
76
+ markdown_output: bool = False,
77
+ cure: bool = False,
78
+ strict: bool = False,
79
+ min_score_override: float | None = None,
80
+ offline: bool = False,
81
+ ) -> SoberAuditReport:
82
+ """Core audit runner for scan, diff, and explain commands."""
83
+ start_time = time.time()
84
+ config = load_config(path)
85
+ if offline:
86
+ config.offline_mode = True
87
+ manifest = ManifestInspector(path)
88
+
89
+ # Filter out files ignored by config
90
+ filtered_files = []
91
+ for f in files:
92
+ rel = str(f.path.relative_to(path) if f.path.is_relative_to(path) else f.path)
93
+ if not config.is_path_ignored(rel):
94
+ filtered_files.append(f)
95
+ files = filtered_files
96
+
97
+ if not files:
98
+ empty_report = SoberAuditReport([], files_scanned=0, duration_seconds=0.0)
99
+ if json_output:
100
+ print(json.dumps({"error": "No supported source files found to audit."}))
101
+ elif sarif_output:
102
+ print(json.dumps(empty_report.to_sarif(), indent=2))
103
+ elif markdown_output:
104
+ print(empty_report.to_markdown())
105
+ else:
106
+ console.print("[bold yellow]No supported source files (.py, .ts, .tsx, .js, .jsx) found to audit.[/]")
107
+ return empty_report
108
+
109
+ # Run detectors
110
+ all_issues = []
111
+ for f in files:
112
+ if config.is_detector_enabled("hallucinations"):
113
+ all_issues.extend(audit_hallucinations(f, path, manifest_inspector=manifest, config=config))
114
+ if config.is_detector_enabled("antipatterns"):
115
+ all_issues.extend(audit_antipatterns(f, path))
116
+ if config.is_detector_enabled("ghost_code"):
117
+ all_issues.extend(audit_ghost_code(f, path))
118
+ if config.is_detector_enabled("security"):
119
+ all_issues.extend(audit_security(f, path, custom_secret_patterns=config.custom_secret_patterns))
120
+ if config.is_detector_enabled("chatter"):
121
+ all_issues.extend(audit_chatter(f, path))
122
+
123
+ if config.is_detector_enabled("duplicates"):
124
+ all_issues.extend(audit_duplicates(files, path))
125
+
126
+ duration = time.time() - start_time
127
+ report = SoberAuditReport(all_issues, files_scanned=len(files), duration_seconds=duration)
128
+
129
+ # Render outputs
130
+ if json_output:
131
+ print(json.dumps(report.to_dict(), indent=2))
132
+ elif sarif_output:
133
+ print(json.dumps(report.to_sarif(), indent=2))
134
+ elif markdown_output:
135
+ print(report.to_markdown())
136
+ else:
137
+ render_report(report)
138
+
139
+ # Optional auto-cure
140
+ if cure:
141
+ cured = apply_cure(report.issues, path)
142
+ if not (json_output or sarif_output or markdown_output):
143
+ render_cure_results(cured)
144
+
145
+ # Strict mode validation
146
+ effective_min_score = min_score_override if min_score_override is not None else config.min_score
147
+ should_fail = False
148
+
149
+ if strict:
150
+ if report.score < effective_min_score:
151
+ should_fail = True
152
+ if config.fail_on_critical and report.critical_count > 0:
153
+ should_fail = True
154
+
155
+ if should_fail:
156
+ if not (json_output or sarif_output or markdown_output):
157
+ console.print(
158
+ f"\n[bold red]❌ STRICT CHECK FAILED: Sober Score is {report.score}% (<{effective_min_score}%) or critical issues detected.[/]\n"
159
+ )
160
+ raise typer.Exit(1)
161
+
162
+ return report
163
+
164
+
165
+ @app.command(name="scan")
166
+ def scan(
167
+ path: Path = typer.Argument(
168
+ Path("."),
169
+ help="Target repository or folder path to audit (defaults to current directory).",
170
+ exists=True,
171
+ file_okay=False,
172
+ dir_okay=True,
173
+ resolve_path=True,
174
+ ),
175
+ all_files: bool = typer.Option(
176
+ False,
177
+ "--all",
178
+ "-a",
179
+ help="Audit all codebase files instead of only uncommitted / modified git changes.",
180
+ ),
181
+ cure: bool = typer.Option(
182
+ False,
183
+ "--cure",
184
+ "-c",
185
+ help="Automatically sanitize and strip leftover debug statements, markdown fences, & AI debris.",
186
+ ),
187
+ strict: bool = typer.Option(
188
+ False,
189
+ "--strict",
190
+ "-s",
191
+ help="Fail with exit code 1 if the Sober Score is below threshold or has critical issues.",
192
+ ),
193
+ min_score: float | None = typer.Option(
194
+ None,
195
+ "--min-score",
196
+ help="Override minimum score threshold for strict mode (default: 95.0).",
197
+ ),
198
+ offline: bool = typer.Option(
199
+ False,
200
+ "--offline",
201
+ help="Disable external network queries (PyPI/npm) and perform air-gapped manifest-only audits.",
202
+ ),
203
+ json_output: bool = typer.Option(
204
+ False,
205
+ "--json",
206
+ "-j",
207
+ help="Output raw JSON results for CI/CD integrations.",
208
+ ),
209
+ sarif_output: bool = typer.Option(
210
+ False,
211
+ "--sarif",
212
+ help="Output SARIF 2.1.0 format for GitHub Code Scanning Alerts.",
213
+ ),
214
+ markdown_output: bool = typer.Option(
215
+ False,
216
+ "--markdown",
217
+ "-m",
218
+ help="Output GitHub Flavored Markdown summary suitable for PR comments.",
219
+ ),
220
+ ):
221
+ """Scan code for AI hallucinations, slopsquatting, ghost code, security leaks, and hangover slop."""
222
+ if not (json_output or sarif_output or markdown_output):
223
+ print_banner()
224
+
225
+ files = []
226
+ if not all_files:
227
+ files = get_git_modified_files(path)
228
+ if not files and not (json_output or sarif_output or markdown_output):
229
+ console.print("[dim]No uncommitted git changes detected. Auditing all repository files...[/]\n")
230
+ files = get_all_code_files(path)
231
+ else:
232
+ files = get_all_code_files(path)
233
+
234
+ _execute_audit(
235
+ files=files,
236
+ path=path,
237
+ json_output=json_output,
238
+ sarif_output=sarif_output,
239
+ markdown_output=markdown_output,
240
+ cure=cure,
241
+ strict=strict,
242
+ min_score_override=min_score,
243
+ offline=offline,
244
+ )
245
+
246
+
247
+ @app.command(name="diff")
248
+ def _diff_command(
249
+ revision: str = typer.Argument(
250
+ ...,
251
+ help="Git revision or commit range to audit (e.g. 'HEAD~1', 'main..HEAD', 'origin/main').",
252
+ ),
253
+ path: Path = typer.Option(
254
+ Path("."),
255
+ "--path",
256
+ "-p",
257
+ help="Target repository root directory.",
258
+ exists=True,
259
+ file_okay=False,
260
+ dir_okay=True,
261
+ resolve_path=True,
262
+ ),
263
+ cure: bool = typer.Option(False, "--cure", "-c", help="Auto-sanitize detected debug statements."),
264
+ strict: bool = typer.Option(False, "--strict", "-s", help="Fail if sobriety threshold not met."),
265
+ min_score: float | None = typer.Option(None, "--min-score", help="Override minimum score threshold."),
266
+ offline: bool = typer.Option(False, "--offline", help="Perform offline manifest audit without external network calls."),
267
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON."),
268
+ sarif_output: bool = typer.Option(False, "--sarif", help="Output SARIF 2.1.0."),
269
+ markdown_output: bool = typer.Option(False, "--markdown", "-m", help="Output Markdown report."),
270
+ ):
271
+ """Audit code changes introduced across a specific git commit range or branch."""
272
+ if not (json_output or sarif_output or markdown_output):
273
+ print_banner()
274
+ console.print(f"[bold cyan]Auditing changes in git range:[/] [yellow]{revision}[/]\n")
275
+
276
+ files = get_git_diff_range_files(path, revision)
277
+ _execute_audit(
278
+ files=files,
279
+ path=path,
280
+ json_output=json_output,
281
+ sarif_output=sarif_output,
282
+ markdown_output=markdown_output,
283
+ cure=cure,
284
+ strict=strict,
285
+ min_score_override=min_score,
286
+ offline=offline,
287
+ )
288
+
289
+
290
+ @app.command(name="watch")
291
+ def _watch_command(
292
+ path: Path = typer.Argument(
293
+ Path("."),
294
+ help="Target repository to monitor in real-time.",
295
+ exists=True,
296
+ file_okay=False,
297
+ dir_okay=True,
298
+ resolve_path=True,
299
+ ),
300
+ interval: float = typer.Option(0.5, "--interval", "-i", help="Polling interval in seconds."),
301
+ cure: bool = typer.Option(False, "--cure", "-c", help="Auto-cure detected debug statements and chatter on save."),
302
+ ):
303
+ """Live continuous watcher daemon for real-time AI vibe-coding feedback."""
304
+ print_banner()
305
+ console.print(f"[bold cyan]👀 SOBER Live Watcher active on {path.resolve()}[/] [dim](Ctrl+C to exit)[/]\n")
306
+
307
+ def _on_change():
308
+ console.clear()
309
+ print_banner()
310
+ console.print("[bold yellow]⚡ Change detected! Re-auditing sobriety...[/]\n")
311
+ files = get_git_modified_files(path)
312
+ if not files:
313
+ files = get_all_code_files(path)
314
+ _execute_audit(files=files, path=path, cure=cure)
315
+
316
+ watcher = LiveSoberWatcher(path, poll_interval=interval)
317
+ try:
318
+ watcher.watch(_on_change)
319
+ except KeyboardInterrupt:
320
+ console.print("\n[bold cyan]SOBER Live Watcher stopped.[/]")
321
+
322
+
323
+ @app.command(name="explain")
324
+ def _explain_command(
325
+ path: Path = typer.Argument(
326
+ Path("."),
327
+ help="Target repository or folder path to audit.",
328
+ exists=True,
329
+ file_okay=False,
330
+ dir_okay=True,
331
+ resolve_path=True,
332
+ ),
333
+ all_files: bool = typer.Option(False, "--all", "-a", help="Audit all files instead of only uncommitted changes."),
334
+ ):
335
+ """Generate high-density prompt context for autonomous coding agents (Claude, Cursor, Aider) to self-heal."""
336
+ files = get_all_code_files(path) if all_files else get_git_modified_files(path)
337
+ if not files and not all_files:
338
+ files = get_all_code_files(path)
339
+
340
+ config = load_config(path)
341
+ manifest = ManifestInspector(path)
342
+
343
+ all_issues = []
344
+ for f in files:
345
+ if config.is_detector_enabled("hallucinations"):
346
+ all_issues.extend(audit_hallucinations(f, path, manifest_inspector=manifest, config=config))
347
+ if config.is_detector_enabled("antipatterns"):
348
+ all_issues.extend(audit_antipatterns(f, path))
349
+ if config.is_detector_enabled("ghost_code"):
350
+ all_issues.extend(audit_ghost_code(f, path))
351
+ if config.is_detector_enabled("security"):
352
+ all_issues.extend(audit_security(f, path, custom_secret_patterns=config.custom_secret_patterns))
353
+ if config.is_detector_enabled("chatter"):
354
+ all_issues.extend(audit_chatter(f, path))
355
+
356
+ if config.is_detector_enabled("duplicates"):
357
+ all_issues.extend(audit_duplicates(files, path))
358
+
359
+ report = SoberAuditReport(all_issues, files_scanned=len(files), duration_seconds=0.0)
360
+ prompt = generate_agent_remediation_prompt(
361
+ issues=report.issues,
362
+ score=report.score,
363
+ grade=report.grade,
364
+ status_label=report.status_label,
365
+ files_scanned=len(files),
366
+ )
367
+ print(prompt)
368
+
369
+
370
+ @app.command(name="cure")
371
+ def _cure_command(
372
+ path: Path = typer.Argument(
373
+ Path("."),
374
+ help="Target folder to sanitize debug debris and AI chatter.",
375
+ exists=True,
376
+ file_okay=False,
377
+ dir_okay=True,
378
+ resolve_path=True,
379
+ ),
380
+ all_files: bool = typer.Option(False, "--all", "-a", help="Cure all files instead of only uncommitted changes."),
381
+ ):
382
+ """Standalone sanitization tool: auto-strip leftover debug statements, debuggers, and AI chatter."""
383
+ print_banner()
384
+ files = get_all_code_files(path) if all_files else get_git_modified_files(path)
385
+ if not files and not all_files:
386
+ files = get_all_code_files(path)
387
+
388
+ all_issues = []
389
+ for f in files:
390
+ all_issues.extend(audit_ghost_code(f, path))
391
+ all_issues.extend(audit_chatter(f, path))
392
+
393
+ cured = apply_cure(all_issues, path)
394
+ render_cure_results(cured)
395
+
396
+
397
+ @hook_app.command(name="install")
398
+ def _hook_install(
399
+ path: Path = typer.Argument(Path("."), help="Target Git repository root path."),
400
+ ):
401
+ """Install a native Git pre-commit hook that runs sober scan before every commit."""
402
+ success, msg = install_git_hook(path)
403
+ if success:
404
+ console.print(f"[bold green]✔ Successfully installed sober pre-commit hook at {msg}[/]\n")
405
+ else:
406
+ console.print(f"[bold red]❌ {msg}[/]\n")
407
+ raise typer.Exit(1)
408
+
409
+
410
+ @hook_app.command(name="uninstall")
411
+ def _hook_uninstall(
412
+ path: Path = typer.Argument(Path("."), help="Target Git repository root path."),
413
+ ):
414
+ """Remove the sober Git pre-commit hook."""
415
+ success, msg = uninstall_git_hook(path)
416
+ if success:
417
+ console.print(f"[bold green]✔ {msg}[/]\n")
418
+ else:
419
+ console.print(f"[bold yellow]{msg}[/]\n")
420
+
421
+
422
+ @hook_app.command(name="status")
423
+ def _hook_status(
424
+ path: Path = typer.Argument(Path("."), help="Target Git repository root path."),
425
+ ):
426
+ """Check the status of the Git pre-commit hook."""
427
+ installed = get_git_hook_status(path)
428
+ hooks_dir = get_git_hooks_dir(path)
429
+ hook_path = str(hooks_dir / "pre-commit") if hooks_dir else ""
430
+ render_hook_status(installed, hook_path)
431
+
432
+
433
+ @ci_app.command(name="init")
434
+ def _ci_init(
435
+ path: Path = typer.Argument(Path("."), help="Target repository root path."),
436
+ ):
437
+ """Initialize a GitHub Actions workflow for automated PR sobriety checks."""
438
+ wf_path = init_github_actions_workflow(path)
439
+ console.print(f"[bold green]✔ Generated GitHub Actions workflow at {wf_path}[/]\n")
440
+
441
+
442
+ @app.command(name="init")
443
+ def _init_command(
444
+ path: Path = typer.Argument(Path("."), help="Target directory to create sober.toml."),
445
+ ):
446
+ """Initialize a starter sober.toml configuration file."""
447
+ target_file = path / "sober.toml"
448
+ if target_file.exists():
449
+ console.print(f"[bold yellow]Configuration file {target_file} already exists.[/]")
450
+ raise typer.Exit(0)
451
+
452
+ starter_config = """# SOBER Configuration File (v2.0)
453
+ # Documentation: https://github.com/shenald-dev/sober
454
+
455
+ [sober]
456
+ min_score = 95.0
457
+ fail_on_critical = true
458
+ max_workers = 10
459
+ offline_mode = false
460
+
461
+ # Paths to ignore during audits
462
+ ignore_paths = [
463
+ "tests/*",
464
+ "docs/*",
465
+ "migrations/*",
466
+ ]
467
+
468
+ # Allowed internal or proprietary packages that should never trigger hallucination alerts
469
+ allowed_packages = [
470
+ "@my-org/*",
471
+ # "internal_company_sdk",
472
+ ]
473
+
474
+ # Disable specific detectors if needed
475
+ # disabled_detectors = ["duplicates"]
476
+
477
+ # Custom proprietary secret regex patterns
478
+ # [[sober.custom_secret_patterns]]
479
+ # name = "Company Internal API Token"
480
+ # pattern = "comp_[A-Za-z0-9]{32}"
481
+ """
482
+ target_file.write_text(starter_config, encoding="utf-8")
483
+ console.print(f"[bold green]✔ Initialized starter configuration at {target_file}[/]")
484
+
485
+
486
+ def main():
487
+ app()
488
+
489
+
490
+ if __name__ == "__main__":
491
+ main()
sober/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core audit, scanning, scoring, and sanitizing engines."""
sober/core/ci.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ CI / GitHub Actions Workflow Generator for SOBER.
3
+ Bootstraps ready-to-run GitHub Actions workflows for automated PR sobriety checks.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ GITHUB_ACTIONS_WORKFLOW = """name: SOBER Code Sobriety Radar
9
+
10
+ on:
11
+ pull_request:
12
+ branches: [main, master]
13
+ push:
14
+ branches: [main, master]
15
+
16
+ jobs:
17
+ sober-audit:
18
+ name: AI Hangover & Security Audit
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - name: Checkout Code
22
+ uses: actions/checkout@v4
23
+ with:
24
+ fetch-depth: 0
25
+
26
+ - name: Set up Python
27
+ uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.11"
30
+
31
+ - name: Install SOBER
32
+ run: |
33
+ pip install git+https://github.com/shenald-dev/sober.git
34
+
35
+ - name: Run SOBER Audit (SARIF & Markdown)
36
+ run: |
37
+ sober scan --all --sarif > sober.sarif
38
+ sober scan --all --markdown > sober-summary.md
39
+ cat sober-summary.md >> $GITHUB_STEP_SUMMARY
40
+ continue-on-error: true
41
+
42
+ - name: Upload SARIF to GitHub Code Scanning
43
+ uses: github/codeql-action/upload-sarif@v3
44
+ if: always()
45
+ with:
46
+ sarif_file: sober.sarif
47
+ category: sober-hangover-guard
48
+
49
+ - name: Strict Sobriety Gate
50
+ run: |
51
+ sober scan --all --strict --min-score 90
52
+ """
53
+
54
+
55
+ def init_github_actions_workflow(repo_root: Path) -> Path:
56
+ """Create .github/workflows/sober-guard.yml."""
57
+ workflows_dir = repo_root / ".github" / "workflows"
58
+ workflows_dir.mkdir(parents=True, exist_ok=True)
59
+ workflow_file = workflows_dir / "sober-guard.yml"
60
+ workflow_file.write_text(GITHUB_ACTIONS_WORKFLOW, encoding="utf-8")
61
+ return workflow_file
sober/core/config.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ Configuration loader for SOBER - supports sober.toml and pyproject.toml [tool.sober].
3
+ """
4
+
5
+ import fnmatch
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ try:
12
+ import tomllib
13
+ except ModuleNotFoundError:
14
+ import tomli as tomllib # type: ignore
15
+
16
+
17
+ @dataclass
18
+ class SoberConfig:
19
+ min_score: float = 95.0
20
+ fail_on_critical: bool = True
21
+ ignore_paths: list[str] = field(default_factory=list)
22
+ allowed_packages: list[str] = field(default_factory=list)
23
+ disabled_detectors: list[str] = field(default_factory=list)
24
+ custom_secret_patterns: list[dict[str, str]] = field(default_factory=list)
25
+ max_workers: int = 10
26
+ offline_mode: bool = False
27
+
28
+ def is_path_ignored(self, rel_path: str) -> bool:
29
+ """Check if relative file path matches any ignore glob patterns."""
30
+ normalized = rel_path.replace("\\", "/")
31
+ for pattern in self.ignore_paths:
32
+ norm_pattern = pattern.replace("\\", "/")
33
+ if fnmatch.fnmatch(normalized, norm_pattern) or fnmatch.fnmatch(Path(normalized).name, norm_pattern):
34
+ return True
35
+ return False
36
+
37
+ def is_package_whitelisted(self, package_name: str) -> bool:
38
+ """Check if package name matches any allowed package names or wildcard scopes."""
39
+ clean_name = package_name.lower().strip()
40
+ for allowed in self.allowed_packages:
41
+ norm_allowed = allowed.lower().strip()
42
+ if norm_allowed == clean_name or fnmatch.fnmatch(clean_name, norm_allowed):
43
+ return True
44
+ return False
45
+
46
+ def is_detector_enabled(self, detector_name: str) -> bool:
47
+ """Check if detector is enabled."""
48
+ return detector_name.lower() not in [d.lower() for d in self.disabled_detectors]
49
+
50
+
51
+ def load_config(root_path: Path) -> SoberConfig:
52
+ """Load configuration from sober.toml, .sober.toml, or pyproject.toml."""
53
+ candidates = [
54
+ root_path / "sober.toml",
55
+ root_path / ".sober.toml",
56
+ root_path / "pyproject.toml",
57
+ ]
58
+
59
+ for candidate in candidates:
60
+ if candidate.is_file():
61
+ try:
62
+ content = candidate.read_bytes()
63
+ data = tomllib.loads(content.decode("utf-8", errors="replace"))
64
+
65
+ section: dict[str, Any] = {}
66
+ if candidate.name == "pyproject.toml":
67
+ section = data.get("tool", {}).get("sober", {})
68
+ else:
69
+ section = data.get("sober", data)
70
+
71
+ if not section and candidate.name == "pyproject.toml":
72
+ continue
73
+
74
+ return SoberConfig(
75
+ min_score=float(section.get("min_score", 95.0)),
76
+ fail_on_critical=bool(section.get("fail_on_critical", True)),
77
+ ignore_paths=list(section.get("ignore_paths", [])),
78
+ allowed_packages=list(section.get("allowed_packages", [])),
79
+ disabled_detectors=list(section.get("disabled_detectors", [])),
80
+ custom_secret_patterns=list(section.get("custom_secret_patterns", [])),
81
+ max_workers=int(section.get("max_workers", 10)),
82
+ offline_mode=bool(section.get("offline_mode", False)),
83
+ )
84
+ except Exception:
85
+ logging.debug(f"Failed to parse config candidate {candidate}", exc_info=True)
86
+ # If TOML has errors, skip to next candidate
87
+ continue
88
+
89
+ return SoberConfig()