slopguard-cli 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/.gitignore +9 -0
  2. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/PKG-INFO +8 -7
  3. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/README.md +7 -6
  4. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/pyproject.toml +1 -1
  5. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/__init__.py +1 -1
  6. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/cli.py +71 -18
  7. slopguard_cli-0.4.0/slopguard/precommit.py +121 -0
  8. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/saas/client.py +10 -2
  9. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/saas/credentials.py +8 -2
  10. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/update.py +7 -13
  11. slopguard_cli-0.4.0/tests/conftest.py +22 -0
  12. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_cli.py +2 -2
  13. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_misc.py +2 -2
  14. slopguard_cli-0.4.0/tests/test_precommit.py +78 -0
  15. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_saas_auth.py +4 -4
  16. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_saas_client.py +6 -4
  17. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_update.py +8 -20
  18. slopguard_cli-0.2.0/tests/conftest.py +0 -12
  19. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/.ruff.toml +0 -0
  20. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/Makefile +0 -0
  21. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/scripts/generate_seed_data.py +0 -0
  22. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/__main__.py +0 -0
  23. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/config.py +0 -0
  24. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/data/__init__.py +0 -0
  25. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/data/hallucinations_seed.json +0 -0
  26. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/data/popular_packages.json +0 -0
  27. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/models.py +0 -0
  28. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/parsers/__init__.py +0 -0
  29. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/parsers/base.py +0 -0
  30. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/parsers/npm.py +0 -0
  31. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/parsers/python.py +0 -0
  32. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/registry/__init__.py +0 -0
  33. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/registry/base.py +0 -0
  34. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/registry/npm.py +0 -0
  35. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/registry/pypi.py +0 -0
  36. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/report/__init__.py +0 -0
  37. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/report/json.py +0 -0
  38. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/report/terminal.py +0 -0
  39. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/saas/__init__.py +0 -0
  40. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/scoring/__init__.py +0 -0
  41. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/scoring/engine.py +0 -0
  42. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/scoring/signals.py +0 -0
  43. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/__init__.py +0 -0
  44. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/fixtures/.slopguard.yaml +0 -0
  45. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/fixtures/package.json +0 -0
  46. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/fixtures/pyproject.toml +0 -0
  47. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/fixtures/requirements.txt +0 -0
  48. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_parsers_npm.py +0 -0
  49. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_parsers_python.py +0 -0
  50. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_registry_npm.py +0 -0
  51. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_registry_pypi.py +0 -0
  52. {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_scoring_engine.py +0 -0
@@ -52,3 +52,12 @@ out/
52
52
  .env
53
53
  .env.*
54
54
  !.env.example
55
+
56
+ # Web app local config + build output
57
+ apps/web/.env.local
58
+ apps/web/tsconfig.tsbuildinfo
59
+ apps/web/.next/
60
+
61
+ # Probe daily JSONLs are written by the cron; the directory is tracked
62
+ # via .gitkeep, individual files aren't.
63
+ probe-data/observations/*.jsonl
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slopguard-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Defend developers and AI coding agents against slopsquatting (hallucinated package names).
5
5
  Project-URL: Homepage, https://github.com/hariomunknownslab/slopguard
6
6
  Project-URL: Repository, https://github.com/hariomunknownslab/slopguard
@@ -167,18 +167,19 @@ scoring:
167
167
  CLI flags override the file. See [`docs/usage.md`](docs/usage.md) for the full
168
168
  reference.
169
169
 
170
- ## What it does NOT do (v0.1)
170
+ ## What it does NOT do
171
171
 
172
- - No live LLM probing the hallucination database is a static seed for v0.1.
173
- - No SaaS dashboard, no auth, no billing, no telemetry to any remote server.
174
- - No tarpit registry, no defensive package registration.
172
+ - No dashboard, no auth, no accounts, no billing, no telemetry. The
173
+ CLI is fully offline-capable; `slopguard update` is the only
174
+ outbound call beyond the npm + PyPI registry probes, and it just
175
+ fetches a static JSON file from GitHub Pages.
176
+ - No defensive package registration / tarpit.
175
177
  - No Cursor / Claude Code / Copilot IDE plugins.
176
178
  - No support for crates.io, pkg.go.dev, Maven Central, RubyGems, NuGet —
177
179
  Python and JavaScript only.
178
180
  - No license scanning, no CVE matching, no SBOM generation.
179
- - No remote configuration, no SaaS API client.
180
181
 
181
- The full v0.2+ roadmap is tracked in the build spec, section 14.
182
+ Everything is MIT, free forever. Fork it.
182
183
 
183
184
  ## Privacy & trust
184
185
 
@@ -129,18 +129,19 @@ scoring:
129
129
  CLI flags override the file. See [`docs/usage.md`](docs/usage.md) for the full
130
130
  reference.
131
131
 
132
- ## What it does NOT do (v0.1)
132
+ ## What it does NOT do
133
133
 
134
- - No live LLM probing the hallucination database is a static seed for v0.1.
135
- - No SaaS dashboard, no auth, no billing, no telemetry to any remote server.
136
- - No tarpit registry, no defensive package registration.
134
+ - No dashboard, no auth, no accounts, no billing, no telemetry. The
135
+ CLI is fully offline-capable; `slopguard update` is the only
136
+ outbound call beyond the npm + PyPI registry probes, and it just
137
+ fetches a static JSON file from GitHub Pages.
138
+ - No defensive package registration / tarpit.
137
139
  - No Cursor / Claude Code / Copilot IDE plugins.
138
140
  - No support for crates.io, pkg.go.dev, Maven Central, RubyGems, NuGet —
139
141
  Python and JavaScript only.
140
142
  - No license scanning, no CVE matching, no SBOM generation.
141
- - No remote configuration, no SaaS API client.
142
143
 
143
- The full v0.2+ roadmap is tracked in the build spec, section 14.
144
+ Everything is MIT, free forever. Fork it.
144
145
 
145
146
  ## Privacy & trust
146
147
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "slopguard-cli"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Defend developers and AI coding agents against slopsquatting (hallucinated package names)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.4.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -1,4 +1,4 @@
1
- """SlopGuard CLI — ``scan`` + ``version`` subcommands."""
1
+ """SlopGuard CLI — ``scan``, ``update``, ``login``, ``whoami``, ``logout``, ``version``."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -274,7 +274,10 @@ def scan_cmd(
274
274
  bool,
275
275
  typer.Option(
276
276
  "--upload",
277
- help="Upload the scan to the SlopGuard SaaS after scanning. Requires `slopguard login`.",
277
+ help=(
278
+ "After scanning, POST the JSON report to a self-hosted "
279
+ "SlopGuard instance. Requires `slopguard login --api-url …` first."
280
+ ),
278
281
  ),
279
282
  ] = False,
280
283
  ) -> None:
@@ -370,14 +373,51 @@ def version_cmd() -> None:
370
373
  print(__version__)
371
374
 
372
375
 
376
+ precommit_app = typer.Typer(
377
+ name="pre-commit",
378
+ help="Manage the git pre-commit hook that blocks HALLUCINATED commits.",
379
+ no_args_is_help=True,
380
+ )
381
+ app.add_typer(precommit_app, name="pre-commit")
382
+
383
+
384
+ @precommit_app.command("install")
385
+ def precommit_install_cmd(
386
+ force: Annotated[
387
+ bool,
388
+ typer.Option("--force", help="Overwrite an existing .git/hooks/pre-commit."),
389
+ ] = False,
390
+ ) -> None:
391
+ """Install a git pre-commit hook that runs ``slopguard scan`` on staged manifests."""
392
+ from slopguard.precommit import PrecommitError, install
393
+
394
+ try:
395
+ path = install(force=force)
396
+ except PrecommitError as exc:
397
+ raise _error_exit(str(exc)) from exc
398
+ Console().print(f"[green]Installed[/green] {path}")
399
+
400
+
401
+ @precommit_app.command("uninstall")
402
+ def precommit_uninstall_cmd() -> None:
403
+ """Remove the slopguard pre-commit hook (only if we installed it)."""
404
+ from slopguard.precommit import PrecommitError, uninstall
405
+
406
+ try:
407
+ removed = uninstall()
408
+ except PrecommitError as exc:
409
+ raise _error_exit(str(exc)) from exc
410
+ Console().print("[green]Removed.[/green]" if removed else "[yellow]Nothing to remove.[/yellow]")
411
+
412
+
373
413
  @app.command("update")
374
414
  def update_cmd() -> None:
375
- """Refresh the local hallucination DB from the SlopGuard SaaS feed.
415
+ """Refresh the local hallucination DB from the public GitHub Pages feed.
376
416
 
377
- No auth required. Downloads the public registry from
378
- ``$SLOPGUARD_API_URL/v1/hallucinations`` (default: the SlopGuard SaaS)
379
- and caches it at ``~/.cache/slopguard/hallucinations_db.json`` for the
380
- next scan to read.
417
+ No auth required. Default source:
418
+ ``https://hariomunknownslab.github.io/slopguard/db.json`` (override via
419
+ ``SLOPGUARD_DB_URL``). Cached at
420
+ ``~/.cache/slopguard/hallucinations_db.json`` for the next scan.
381
421
  """
382
422
  from slopguard.update import run
383
423
 
@@ -391,12 +431,20 @@ def update_cmd() -> None:
391
431
 
392
432
 
393
433
  def _resolve_api_url(flag_value: str | None) -> str:
394
- """API URL resolution order: --api-url > $SLOPGUARD_API_URL > default."""
395
- import os
434
+ """API URL resolution order: --api-url > $SLOPGUARD_API_URL.
396
435
 
397
- from slopguard.saas.client import DEFAULT_API_URL
436
+ There is no default endpoint. Self-hosters supply their own URL —
437
+ SlopGuard does not run a hosted SaaS.
438
+ """
439
+ import os
398
440
 
399
- return flag_value or os.environ.get("SLOPGUARD_API_URL") or DEFAULT_API_URL
441
+ url = flag_value or os.environ.get("SLOPGUARD_API_URL", "")
442
+ if not url:
443
+ raise _error_exit(
444
+ "no API URL set. Pass --api-url https://your.instance/ or set SLOPGUARD_API_URL. "
445
+ "SlopGuard is open-source and self-hosted; there is no hosted SaaS."
446
+ )
447
+ return url
400
448
 
401
449
 
402
450
  @app.command("login")
@@ -412,27 +460,32 @@ def login_cmd(
412
460
  str | None,
413
461
  typer.Option(
414
462
  "--api-url",
415
- help="SlopGuard API base URL. Defaults to $SLOPGUARD_API_URL or production.",
463
+ help="Self-hosted SlopGuard API base URL. Falls back to $SLOPGUARD_API_URL.",
416
464
  ),
417
465
  ] = None,
418
466
  ) -> None:
419
- """Authenticate the CLI with a SlopGuard API token.
467
+ """Authenticate the CLI with a self-hosted SlopGuard API token.
420
468
 
421
- Mint a token in the dashboard at *<api>/app/<org>/tokens* and paste it
422
- here. The token is stored at ``~/.config/slopguard/credentials.toml``
423
- with mode 0600.
469
+ Run your own instance with ``docker compose up`` from the repo
470
+ root. Mint a token in your dashboard at
471
+ ``<your-instance>/app/<org-slug>/tokens`` and paste it here. The
472
+ token is stored at ``~/.config/slopguard/credentials.toml`` with
473
+ mode 0600.
424
474
  """
425
475
  from slopguard.saas import Credentials, save_credentials
426
476
  from slopguard.saas.client import ApiError, get_me
427
477
 
428
- api = _resolve_api_url(api_url)
478
+ # Validate the token format BEFORE we worry about the API URL —
479
+ # gives a clearer error if the user pasted gibberish.
429
480
  plaintext = token or typer.prompt("API token (input hidden)", hide_input=True)
430
481
  if not plaintext.startswith(("sg_live_", "sg_test_")):
431
482
  raise _error_exit(
432
483
  "token must start with sg_live_ or sg_test_; mint one at "
433
- f"{api.rstrip('/')}/app/<org-slug>/tokens"
484
+ "<your-self-hosted-instance>/app/<org-slug>/tokens"
434
485
  )
435
486
 
487
+ api = _resolve_api_url(api_url)
488
+
436
489
  try:
437
490
  me = get_me(api, plaintext)
438
491
  except ApiError as exc:
@@ -0,0 +1,121 @@
1
+ """``slopguard pre-commit install`` — writes a ``.git/hooks/pre-commit``.
2
+
3
+ The hook runs ``slopguard scan`` on the staged manifest files and
4
+ blocks the commit if it finds a HALLUCINATED dependency. Per
5
+ v2 spec § 10.4.
6
+
7
+ The hook itself is tiny — it shells out to ``slopguard scan
8
+ --paths <staged-manifests>`` so the scanner logic stays in one place.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ HOOK_TEMPLATE = """\
20
+ #!/bin/sh
21
+ # Auto-generated by `slopguard pre-commit install`.
22
+ # Aborts the commit on HALLUCINATED package findings in staged manifests.
23
+ # Re-generate by re-running `slopguard pre-commit install`.
24
+
25
+ set -e
26
+
27
+ # Find staged manifest files.
28
+ STAGED=$(git diff --cached --name-only --diff-filter=ACMR | tr '\\n' ' ')
29
+ MANIFESTS=""
30
+ for f in $STAGED; do
31
+ case "$f" in
32
+ package.json|*/package.json) MANIFESTS="$MANIFESTS $f" ;;
33
+ package-lock.json|*/package-lock.json) MANIFESTS="$MANIFESTS $f" ;;
34
+ requirements*.txt|*/requirements*.txt) MANIFESTS="$MANIFESTS $f" ;;
35
+ pyproject.toml|*/pyproject.toml) MANIFESTS="$MANIFESTS $f" ;;
36
+ Pipfile|*/Pipfile) MANIFESTS="$MANIFESTS $f" ;;
37
+ esac
38
+ done
39
+
40
+ # No manifest changes → nothing to do.
41
+ if [ -z "$MANIFESTS" ]; then
42
+ exit 0
43
+ fi
44
+
45
+ # Scan each manifest. Exit on first HALLUCINATED finding.
46
+ for m in $MANIFESTS; do
47
+ if ! slopguard scan "$m" --fail-on hallucinated; then
48
+ echo ""
49
+ echo "slopguard pre-commit: blocked by HALLUCINATED finding."
50
+ echo " fix: remove the bad package, then re-stage the manifest."
51
+ echo " bypass (NOT recommended): git commit --no-verify"
52
+ exit 1
53
+ fi
54
+ done
55
+ """
56
+
57
+
58
+ class PrecommitError(Exception):
59
+ """Raised when the hook can't be installed (e.g. not in a git repo)."""
60
+
61
+
62
+ def _git_dir() -> Path:
63
+ """Return the ``.git/`` directory of the current repo, or raise."""
64
+ try:
65
+ out = subprocess.run(
66
+ ["git", "rev-parse", "--git-dir"],
67
+ check=True,
68
+ capture_output=True,
69
+ text=True,
70
+ )
71
+ except FileNotFoundError as exc:
72
+ raise PrecommitError("git not found on PATH") from exc
73
+ except subprocess.CalledProcessError as exc:
74
+ raise PrecommitError("not inside a git repository") from exc
75
+ git_dir = Path(out.stdout.strip())
76
+ if not git_dir.is_absolute():
77
+ git_dir = Path.cwd() / git_dir
78
+ return git_dir
79
+
80
+
81
+ def install(*, force: bool = False) -> Path:
82
+ """Write the pre-commit hook into ``.git/hooks/``. Returns its path.
83
+
84
+ If a hook already exists and ``force`` is False, leaves it alone
85
+ and raises ``PrecommitError`` — clobbering a user's existing hook
86
+ silently would be rude.
87
+ """
88
+ git_dir = _git_dir()
89
+ hook_path = git_dir / "hooks" / "pre-commit"
90
+ if hook_path.exists() and not force:
91
+ body = hook_path.read_text(encoding="utf-8", errors="ignore")
92
+ if "slopguard pre-commit install" in body:
93
+ # Already ours — overwrite is fine.
94
+ pass
95
+ else:
96
+ raise PrecommitError(f"{hook_path} already exists. Re-run with --force to overwrite.")
97
+ hook_path.parent.mkdir(parents=True, exist_ok=True)
98
+ hook_path.write_text(HOOK_TEMPLATE, encoding="utf-8")
99
+ # chmod +x
100
+ mode = hook_path.stat().st_mode
101
+ hook_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
102
+ return hook_path
103
+
104
+
105
+ def uninstall() -> bool:
106
+ """Remove the hook if it was installed by us. Returns True if removed."""
107
+ git_dir = _git_dir()
108
+ hook_path = git_dir / "hooks" / "pre-commit"
109
+ if not hook_path.exists():
110
+ return False
111
+ body = hook_path.read_text(encoding="utf-8", errors="ignore")
112
+ if "slopguard pre-commit install" not in body:
113
+ # Not ours — refuse to delete.
114
+ print(
115
+ f"{hook_path} doesn't look like a slopguard-installed hook; "
116
+ "leaving alone. Delete it by hand if you want.",
117
+ file=sys.stderr,
118
+ )
119
+ return False
120
+ os.unlink(hook_path)
121
+ return True
@@ -1,4 +1,9 @@
1
- """Tiny HTTP client for the SlopGuard API."""
1
+ """Tiny HTTP client for the SlopGuard API.
2
+
3
+ There is no canonical hosted SlopGuard API — every user runs their own.
4
+ Set ``SLOPGUARD_API_URL`` or pass ``--api-url`` to point at your
5
+ self-hosted instance.
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
@@ -7,7 +12,10 @@ from typing import Any
7
12
 
8
13
  import httpx
9
14
 
10
- DEFAULT_API_URL = "https://api.slopguard.io"
15
+ # Intentionally empty — no default endpoint. Users must point at their
16
+ # own instance via ``slopguard login --api-url …`` or by setting
17
+ # ``SLOPGUARD_API_URL``.
18
+ DEFAULT_API_URL = ""
11
19
 
12
20
 
13
21
  class ApiError(Exception):
@@ -6,14 +6,20 @@ users on the machine cannot read it (spec §6.6).
6
6
  Layout::
7
7
 
8
8
  [active]
9
- api_url = "https://api.slopguard.io"
9
+ api_url = "https://api.slopguard.example"
10
10
  token = "sg_live_..."
11
11
  org_slug = "acme-security"
12
12
  organization_id = "..."
13
- user_email = "harry@unknownslab.com"
13
+ user_email = "you@example.com"
14
14
 
15
15
  A future ``slopguard switch`` could let multiple profiles live side-by-
16
16
  side; for now only ``[active]`` is read or written.
17
+
18
+ NOTE: The SaaS endpoints these credentials authenticate against were
19
+ deprecated in v0.2 (see apps/cli/slopguard/update.py). The CLI now
20
+ fetches the hallucination DB from GitHub Pages without auth. The
21
+ ``saas`` package is kept for backward compatibility with any 0.1.x
22
+ self-hosted SaaS deployments.
17
23
  """
18
24
 
19
25
  from __future__ import annotations
@@ -1,7 +1,7 @@
1
1
  """``slopguard update`` — refresh the local hallucination DB.
2
2
 
3
- Fetches the curated DB from the SlopGuard GitHub Pages site (no API,
4
- no account, no auth) and writes a seed-shaped file to
3
+ Fetches the curated DB from the public GitHub Pages site (no API, no
4
+ account, no auth) and writes a seed-shaped file to
5
5
  ``~/.cache/slopguard/hallucinations_db.json``. The next ``slopguard
6
6
  scan`` prefers this cached file over the bundled seed (see
7
7
  ``slopguard.data.load_hallucination_db``).
@@ -13,9 +13,7 @@ Updated daily by the probe-cron GitHub Action; each entry goes through
13
13
  PR review before publication (see probe-data/README.md in the repo).
14
14
 
15
15
  Override the URL with ``SLOPGUARD_DB_URL`` to fetch from a fork or
16
- mirror. The legacy ``SLOPGUARD_API_URL`` env var still works for the
17
- 0.1.x SaaS endpoint shape — used as a fallback for backward
18
- compatibility.
16
+ self-hosted mirror.
19
17
  """
20
18
 
21
19
  from __future__ import annotations
@@ -35,18 +33,14 @@ CACHE_PATH = Path.home() / ".cache" / "slopguard" / "hallucinations_db.json"
35
33
  def _db_url() -> str:
36
34
  """Where to fetch the published DB.
37
35
 
38
- Precedence: ``SLOPGUARD_DB_URL`` > legacy
39
- ``SLOPGUARD_API_URL/v1/hallucinations`` > default GitHub Pages.
36
+ Override the default GitHub Pages URL with ``SLOPGUARD_DB_URL``
37
+ when running against a fork or a self-hosted mirror.
40
38
  """
41
- if explicit := os.environ.get("SLOPGUARD_DB_URL"):
42
- return explicit
43
- if legacy := os.environ.get("SLOPGUARD_API_URL"):
44
- return legacy.rstrip("/") + "/v1/hallucinations"
45
- return DEFAULT_DB_URL
39
+ return os.environ.get("SLOPGUARD_DB_URL") or DEFAULT_DB_URL
46
40
 
47
41
 
48
42
  def _convert(wire: dict[str, Any]) -> dict[str, Any]:
49
- """Map the API wire payload to the local seed schema."""
43
+ """Map the public-feed payload to the local seed schema."""
50
44
  entries: list[dict[str, Any]] = []
51
45
  for row in wire.get("entries", []):
52
46
  entries.append(
@@ -0,0 +1,22 @@
1
+ """Shared pytest fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ # Force test isolation from the developer's real ~/.cache. If a previous
10
+ # `slopguard update` left an empty/stale cache file, the hallucination DB
11
+ # loader would pick it up and tests asserting on the bundled seed would
12
+ # break. Pointing CACHE_OVERRIDE at /dev/null/missing makes the loader
13
+ # fall back to the seed every time, regardless of host state.
14
+ from slopguard import data as _data
15
+
16
+ _data.CACHE_OVERRIDE = Path("/__slopguard_test_no_cache__")
17
+ _data.load_hallucination_db.cache_clear()
18
+
19
+
20
+ @pytest.fixture
21
+ def fixtures_dir() -> Path:
22
+ return Path(__file__).parent / "fixtures"
@@ -107,7 +107,7 @@ def runner() -> CliRunner:
107
107
  def test_version_subcommand(runner: CliRunner) -> None:
108
108
  result = runner.invoke(app, ["version"])
109
109
  assert result.exit_code == 0
110
- assert result.stdout.strip() == "0.2.0"
110
+ assert result.stdout.strip() == "0.4.0"
111
111
 
112
112
 
113
113
  def test_scan_fixtures_no_network_terminal(runner: CliRunner, fixtures_dir: Path) -> None:
@@ -183,4 +183,4 @@ def test_module_main_runs() -> None:
183
183
  check=False,
184
184
  )
185
185
  assert result.returncode == 0
186
- assert result.stdout.strip() == "0.2.0"
186
+ assert result.stdout.strip() == "0.4.0"
@@ -40,8 +40,8 @@ def test_update_writes_cache_when_api_reachable(
40
40
  from slopguard import update
41
41
 
42
42
  monkeypatch.setattr(update, "CACHE_PATH", tmp_path / "h.json")
43
- monkeypatch.setenv("SLOPGUARD_API_URL", "http://api.test")
44
- respx.get("http://api.test/v1/hallucinations").mock(
43
+ monkeypatch.setenv("SLOPGUARD_DB_URL", "http://feed.test/db.json")
44
+ respx.get("http://feed.test/db.json").mock(
45
45
  return_value=httpx.Response(
46
46
  200,
47
47
  json={"generated_at": "2026-05-23T22:00:00Z", "count": 0, "entries": []},
@@ -0,0 +1,78 @@
1
+ """Tests for ``slopguard pre-commit install`` / ``uninstall``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from slopguard.precommit import PrecommitError, install, uninstall
12
+
13
+
14
+ @pytest.fixture
15
+ def in_git_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
16
+ """Create a fresh empty git repo and cd into it."""
17
+ subprocess.run(["git", "init", "--quiet"], cwd=tmp_path, check=True)
18
+ monkeypatch.chdir(tmp_path)
19
+ return tmp_path
20
+
21
+
22
+ def test_install_writes_executable_hook(in_git_repo: Path) -> None:
23
+ path = install()
24
+ assert path.exists()
25
+ assert os.access(path, os.X_OK)
26
+ body = path.read_text()
27
+ assert "slopguard pre-commit install" in body # sentinel
28
+ assert "slopguard scan" in body
29
+ assert "--fail-on hallucinated" in body
30
+
31
+
32
+ def test_install_refuses_to_clobber_foreign_hook(in_git_repo: Path) -> None:
33
+ hook = in_git_repo / ".git" / "hooks" / "pre-commit"
34
+ hook.parent.mkdir(parents=True, exist_ok=True)
35
+ hook.write_text("#!/bin/sh\necho something-else\n")
36
+ with pytest.raises(PrecommitError, match="already exists"):
37
+ install()
38
+
39
+
40
+ def test_install_force_overwrites(in_git_repo: Path) -> None:
41
+ hook = in_git_repo / ".git" / "hooks" / "pre-commit"
42
+ hook.parent.mkdir(parents=True, exist_ok=True)
43
+ hook.write_text("#!/bin/sh\necho something-else\n")
44
+ install(force=True)
45
+ assert "slopguard pre-commit install" in hook.read_text()
46
+
47
+
48
+ def test_install_reinstalls_own_hook_without_force(in_git_repo: Path) -> None:
49
+ """Re-running install on our own hook is fine — it's a refresh."""
50
+ install()
51
+ install() # no PrecommitError
52
+ hook = in_git_repo / ".git" / "hooks" / "pre-commit"
53
+ assert "slopguard pre-commit install" in hook.read_text()
54
+
55
+
56
+ def test_uninstall_removes_our_hook(in_git_repo: Path) -> None:
57
+ install()
58
+ assert uninstall() is True
59
+ assert not (in_git_repo / ".git" / "hooks" / "pre-commit").exists()
60
+
61
+
62
+ def test_uninstall_returns_false_when_absent(in_git_repo: Path) -> None:
63
+ assert uninstall() is False
64
+
65
+
66
+ def test_uninstall_refuses_to_delete_foreign_hook(in_git_repo: Path) -> None:
67
+ hook = in_git_repo / ".git" / "hooks" / "pre-commit"
68
+ hook.parent.mkdir(parents=True, exist_ok=True)
69
+ hook.write_text("#!/bin/sh\necho theirs\n")
70
+ assert uninstall() is False
71
+ assert hook.exists()
72
+
73
+
74
+ def test_outside_git_repo_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
75
+ # tmp_path has no .git so git rev-parse fails.
76
+ monkeypatch.chdir(tmp_path)
77
+ with pytest.raises(PrecommitError, match="not inside a git repository"):
78
+ install()
@@ -39,7 +39,7 @@ def test_credentials_round_trip(tmp_path: Path) -> None:
39
39
  token="sg_live_" + "x" * 24,
40
40
  org_slug="acme",
41
41
  organization_id="11111111-1111-1111-1111-111111111111",
42
- user_email="harry@example.com",
42
+ user_email="dev@example.com",
43
43
  ),
44
44
  path=path,
45
45
  )
@@ -85,7 +85,7 @@ def test_login_persists_creds_on_success(
85
85
  assert token.startswith("sg_live_")
86
86
  return saas_client.MeResponse(
87
87
  user_id="user-1",
88
- email="harry@example.com",
88
+ email="dev@example.com",
89
89
  principal_kind="token",
90
90
  active_org=saas_client.MeOrg(
91
91
  id="org-1",
@@ -127,7 +127,7 @@ def test_whoami_with_valid_creds_prints_org(
127
127
  token="sg_live_" + "x" * 24,
128
128
  org_slug="acme",
129
129
  organization_id="org-1",
130
- user_email="harry@example.com",
130
+ user_email="dev@example.com",
131
131
  ),
132
132
  path=cred_path,
133
133
  )
@@ -136,7 +136,7 @@ def test_whoami_with_valid_creds_prints_org(
136
136
  def fake_get_me(api_url: str, token: str, **_: object) -> saas_client.MeResponse:
137
137
  return saas_client.MeResponse(
138
138
  user_id="user-1",
139
- email="harry@example.com",
139
+ email="dev@example.com",
140
140
  principal_kind="token",
141
141
  active_org=saas_client.MeOrg(
142
142
  id="org-1",
@@ -19,7 +19,7 @@ from slopguard.saas.client import (
19
19
 
20
20
  ME_RESPONSE = {
21
21
  "user_id": "user-1",
22
- "email": "harry@example.com",
22
+ "email": "dev@example.com",
23
23
  "clerk_id": None,
24
24
  "principal_kind": "token",
25
25
  "active_organization": {
@@ -41,8 +41,10 @@ ME_RESPONSE = {
41
41
  }
42
42
 
43
43
 
44
- def test_default_api_url_is_production() -> None:
45
- assert DEFAULT_API_URL.startswith("https://")
44
+ def test_default_api_url_is_empty() -> None:
45
+ """No hosted SlopGuard SaaS — DEFAULT_API_URL is intentionally empty.
46
+ Users supply their own via --api-url or $SLOPGUARD_API_URL."""
47
+ assert DEFAULT_API_URL == ""
46
48
 
47
49
 
48
50
  def test_api_error_message_includes_status_and_detail() -> None:
@@ -57,7 +59,7 @@ def test_get_me_returns_parsed_response() -> None:
57
59
  me = get_me("http://api.test", "sg_live_" + "x" * 24)
58
60
  assert isinstance(me, MeResponse)
59
61
  assert isinstance(me.active_org, MeOrg)
60
- assert me.email == "harry@example.com"
62
+ assert me.email == "dev@example.com"
61
63
  assert me.active_org.slug == "acme"
62
64
  assert me.active_org.role == "owner"
63
65
 
@@ -22,7 +22,7 @@ def cache_at_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
22
22
 
23
23
  @pytest.fixture
24
24
  def api_at_test(monkeypatch: pytest.MonkeyPatch) -> None:
25
- monkeypatch.setenv("SLOPGUARD_API_URL", "http://api.test")
25
+ monkeypatch.setenv("SLOPGUARD_DB_URL", "http://feed.test/db.json")
26
26
 
27
27
 
28
28
  @respx.mock
@@ -44,9 +44,7 @@ def test_update_writes_cache(
44
44
  }
45
45
  ],
46
46
  }
47
- respx.get("http://api.test/v1/hallucinations").mock(
48
- return_value=httpx.Response(200, json=payload)
49
- )
47
+ respx.get("http://feed.test/db.json").mock(return_value=httpx.Response(200, json=payload))
50
48
 
51
49
  code = update.run()
52
50
  assert code == 0
@@ -67,7 +65,7 @@ def test_update_writes_cache(
67
65
  def test_update_handles_network_failure(
68
66
  cache_at_tmp: Path, api_at_test: None, capsys: pytest.CaptureFixture[str]
69
67
  ) -> None:
70
- respx.get("http://api.test/v1/hallucinations").mock(side_effect=httpx.ConnectError("nope"))
68
+ respx.get("http://feed.test/db.json").mock(side_effect=httpx.ConnectError("nope"))
71
69
  code = update.run()
72
70
  assert code == 1
73
71
  assert not cache_at_tmp.exists()
@@ -78,7 +76,7 @@ def test_update_handles_network_failure(
78
76
  def test_update_rejects_malformed_payload(
79
77
  cache_at_tmp: Path, api_at_test: None, capsys: pytest.CaptureFixture[str]
80
78
  ) -> None:
81
- respx.get("http://api.test/v1/hallucinations").mock(
79
+ respx.get("http://feed.test/db.json").mock(
82
80
  return_value=httpx.Response(200, json={"entries": [{"name": "x"}]})
83
81
  )
84
82
  code = update.run()
@@ -88,34 +86,24 @@ def test_update_rejects_malformed_payload(
88
86
 
89
87
 
90
88
  def test_default_db_url_is_github_pages(monkeypatch: pytest.MonkeyPatch) -> None:
91
- """When neither override env var is set, fetch from GitHub Pages."""
89
+ """When SLOPGUARD_DB_URL is not set, fetch from GitHub Pages."""
92
90
  monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
93
- monkeypatch.delenv("SLOPGUARD_API_URL", raising=False)
94
91
  assert update._db_url() == update.DEFAULT_DB_URL
95
92
  assert update.DEFAULT_DB_URL.startswith("https://hariomunknownslab.github.io/")
96
93
 
97
94
 
98
- def test_slopguard_db_url_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
95
+ def test_slopguard_db_url_override(monkeypatch: pytest.MonkeyPatch) -> None:
96
+ """SLOPGUARD_DB_URL overrides the default Pages URL."""
99
97
  monkeypatch.setenv("SLOPGUARD_DB_URL", "https://my.mirror/db.json")
100
- monkeypatch.setenv("SLOPGUARD_API_URL", "https://legacy/api") # should be ignored
101
98
  assert update._db_url() == "https://my.mirror/db.json"
102
99
 
103
100
 
104
- def test_legacy_api_url_still_works(monkeypatch: pytest.MonkeyPatch) -> None:
105
- """0.1.x users with SLOPGUARD_API_URL set keep working — we append
106
- /v1/hallucinations the way the SaaS endpoint expected."""
107
- monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
108
- monkeypatch.setenv("SLOPGUARD_API_URL", "https://legacy.example/")
109
- assert update._db_url() == "https://legacy.example/v1/hallucinations"
110
-
111
-
112
101
  @respx.mock
113
102
  def test_update_fetches_from_pages_by_default(
114
103
  cache_at_tmp: Path, monkeypatch: pytest.MonkeyPatch
115
104
  ) -> None:
116
- """End-to-end: no env overrides, hits the Pages URL."""
105
+ """End-to-end: no env override, hits the Pages URL."""
117
106
  monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
118
- monkeypatch.delenv("SLOPGUARD_API_URL", raising=False)
119
107
  respx.get(update.DEFAULT_DB_URL).mock(
120
108
  return_value=httpx.Response(
121
109
  200,
@@ -1,12 +0,0 @@
1
- """Shared pytest fixtures."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- import pytest
8
-
9
-
10
- @pytest.fixture
11
- def fixtures_dir() -> Path:
12
- return Path(__file__).parent / "fixtures"
File without changes
File without changes