judgetap 0.0.2.dev27__tar.gz → 0.0.2.dev29__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 (63) hide show
  1. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/.github/workflows/release.yml +25 -0
  2. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/PKG-INFO +1 -1
  3. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/pyproject.toml +1 -1
  4. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/release-please-config.json +1 -6
  5. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/__init__.py +1 -1
  6. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/_compat.py +8 -6
  7. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/cli.py +16 -4
  8. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/dashboard/data.py +97 -6
  9. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/dashboard/server.py +9 -1
  10. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/secrets.py +3 -0
  11. judgetap-0.0.2.dev29/tests/test_robustness_68.py +177 -0
  12. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/.github/workflows/ci.yml +0 -0
  13. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/.github/workflows/demo.yml +0 -0
  14. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/.gitignore +0 -0
  15. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/.python-version +0 -0
  16. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/.release-please-manifest.json +0 -0
  17. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/CONTRIBUTING.md +0 -0
  18. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/LICENSE +0 -0
  19. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/README.md +0 -0
  20. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/docs/SPEC.md +0 -0
  21. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/docs/demo.tape +0 -0
  22. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/api.py +0 -0
  23. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/cascade.py +0 -0
  24. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/dashboard/__init__.py +0 -0
  25. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/dashboard/page.html +0 -0
  26. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/decision_log.py +0 -0
  27. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/engine.py +0 -0
  28. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/engines/__init__.py +0 -0
  29. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/engines/agentjev.py +0 -0
  30. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/engines/jev.py +0 -0
  31. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/engines/laya.py +0 -0
  32. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/engines/llm.py +0 -0
  33. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/errors.py +0 -0
  34. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/evaluate.py +0 -0
  35. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/guard/__init__.py +0 -0
  36. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/guard/core.py +0 -0
  37. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/guard/hook.py +0 -0
  38. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/guard/install.py +0 -0
  39. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/guard/loop.py +0 -0
  40. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/guard/rules.py +0 -0
  41. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/guard/stop.py +0 -0
  42. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/py.typed +0 -0
  43. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/testing.py +0 -0
  44. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/src/judgetap/types.py +0 -0
  45. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_api.py +0 -0
  46. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_call_accounting.py +0 -0
  47. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_calls.py +0 -0
  48. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_cascade.py +0 -0
  49. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_dashboard.py +0 -0
  50. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_decision_log.py +0 -0
  51. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_engine_jev.py +0 -0
  52. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_engine_llm.py +0 -0
  53. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_engine_local.py +0 -0
  54. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_evaluate.py +0 -0
  55. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_guard_agents.py +0 -0
  56. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_guard_core.py +0 -0
  57. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_guard_hook.py +0 -0
  58. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_guard_loop.py +0 -0
  59. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_guard_rules.py +0 -0
  60. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_guard_stop.py +0 -0
  61. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_questions.py +0 -0
  62. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/tests/test_secrets.py +0 -0
  63. {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev29}/uv.lock +0 -0
@@ -26,6 +26,7 @@ jobs:
26
26
  outputs:
27
27
  released: ${{ steps.rp.outputs.release_created }}
28
28
  tag: ${{ steps.rp.outputs.tag_name }}
29
+ pr_branch: ${{ steps.rp.outputs.pr && fromJSON(steps.rp.outputs.pr).headBranchName || '' }}
29
30
  steps:
30
31
  - id: rp
31
32
  uses: googleapis/release-please-action@v4
@@ -33,6 +34,30 @@ jobs:
33
34
  config-file: release-please-config.json
34
35
  manifest-file: .release-please-manifest.json
35
36
 
37
+ # release-please bumps pyproject.toml but can't edit uv.lock's package
38
+ # table, so relock the release PR's branch whenever release-please updates it.
39
+ relock:
40
+ needs: release-please
41
+ if: github.event_name == 'push' && needs.release-please.outputs.pr_branch != ''
42
+ runs-on: ubuntu-latest
43
+ permissions:
44
+ contents: write
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+ with:
48
+ ref: ${{ needs.release-please.outputs.pr_branch }}
49
+ - uses: astral-sh/setup-uv@v6
50
+ - run: uv lock
51
+ - name: Commit the refreshed lockfile
52
+ env:
53
+ BRANCH: ${{ needs.release-please.outputs.pr_branch }}
54
+ run: |
55
+ git diff --quiet uv.lock && exit 0
56
+ git config user.name "github-actions[bot]"
57
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
58
+ git commit -m "chore: refresh uv.lock for the release" uv.lock
59
+ git push origin "HEAD:refs/heads/$BRANCH"
60
+
36
61
  # Stable: the release-please release (created with GITHUB_TOKEN, which
37
62
  # doesn't trigger `release` events, so no double publish) or a release
38
63
  # published by hand in the GitHub UI.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: judgetap
3
- Version: 0.0.2.dev27
3
+ Version: 0.0.2.dev29
4
4
  Summary: Fast typed decisions (choice, score, yes/no) across Jev-style engines, plus a guard for coding agents. Early development.
5
5
  Project-URL: Homepage, https://github.com/mergesafe-ai/judgetap
6
6
  Author-email: Omer Bar-Ness <omer@zsquared.io>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "judgetap"
3
- version = "0.0.2.dev27"
3
+ version = "0.0.2.dev29"
4
4
  description = "Fast typed decisions (choice, score, yes/no) across Jev-style engines, plus a guard for coding agents. Early development."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -7,12 +7,7 @@
7
7
  "bump-patch-for-minor-pre-major": false,
8
8
  "changelog-path": "CHANGELOG.md",
9
9
  "extra-files": [
10
- "src/judgetap/__init__.py",
11
- {
12
- "type": "toml",
13
- "path": "uv.lock",
14
- "jsonpath": "$.package[?(@.name=='judgetap')].version"
15
- }
10
+ "src/judgetap/__init__.py"
16
11
  ]
17
12
  }
18
13
  },
@@ -23,7 +23,7 @@ from judgetap.errors import (
23
23
  )
24
24
  from judgetap.types import Decision, Question
25
25
 
26
- __version__ = "0.0.2.dev27" # x-release-please-version
26
+ __version__ = "0.0.2.dev29" # x-release-please-version
27
27
 
28
28
  __all__ = [
29
29
  "Cascade",
@@ -14,8 +14,11 @@ OLD_PREFIX, NEW_PREFIX = "SNAPJUDGE_", "JUDGETAP_"
14
14
 
15
15
 
16
16
  def env(name: str) -> str | None:
17
- """$JUDGETAP_<name>, else the old $SNAPJUDGE_<name>."""
18
- return os.environ.get(NEW_PREFIX + name) or os.environ.get(OLD_PREFIX + name)
17
+ """$JUDGETAP_<name>, else the old $SNAPJUDGE_<name>. The new name wins
18
+ whenever it is set, even to an empty string (which reads as unset)."""
19
+ if NEW_PREFIX + name in os.environ:
20
+ return os.environ[NEW_PREFIX + name] or None
21
+ return os.environ.get(OLD_PREFIX + name) or None
19
22
 
20
23
 
21
24
  def default_home() -> Path:
@@ -26,7 +29,6 @@ def default_home() -> Path:
26
29
 
27
30
  def env_source(name: str) -> str | None:
28
31
  """Which variable `env(name)` read: '$JUDGETAP_<name>' or '$SNAPJUDGE_<name>'."""
29
- for prefix in (NEW_PREFIX, OLD_PREFIX):
30
- if os.environ.get(prefix + name):
31
- return f"${prefix}{name}"
32
- return None
32
+ if NEW_PREFIX + name in os.environ: # set, even empty: the old name is ignored
33
+ return f"${NEW_PREFIX}{name}" if os.environ[NEW_PREFIX + name] else None
34
+ return f"${OLD_PREFIX}{name}" if os.environ.get(OLD_PREFIX + name) else None
@@ -190,7 +190,7 @@ def _dashboard(args) -> int:
190
190
  from judgetap.guard.hook import home
191
191
 
192
192
  server = serve(home(), args.port)
193
- url = f"http://127.0.0.1:{args.port}/"
193
+ url = f"http://127.0.0.1:{server.server_address[1]}/" # the bound port (--port 0)
194
194
  print(f"judgetap dashboard on {url} (Ctrl+C to stop); reading {home()}")
195
195
  if not args.no_browser:
196
196
  webbrowser.open(url)
@@ -238,8 +238,14 @@ def _offer_keychain(stdin=None) -> None:
238
238
  def _keys_set(args) -> int:
239
239
  import getpass
240
240
 
241
- from judgetap.secrets import set_key
241
+ from judgetap.secrets import KNOWN_KEYS, set_key
242
242
 
243
+ if args.name not in KNOWN_KEYS:
244
+ print(
245
+ f"{args.name} isn't read by any engine; known keys: {', '.join(KNOWN_KEYS)}."
246
+ )
247
+ print("The llm engine reads its provider's key from the environment only.")
248
+ return 2
243
249
  value = getpass.getpass(f"{args.name}: ") # never echoed, never in argv
244
250
  if not value:
245
251
  print("Nothing entered; not saved.")
@@ -254,9 +260,15 @@ def _keys_set(args) -> int:
254
260
 
255
261
 
256
262
  def _keys_status(args) -> int:
257
- from judgetap.secrets import key_source
263
+ from judgetap.secrets import KNOWN_KEYS, key_source
258
264
 
259
- for name in args.names or ["TYPESAFE_API_KEY"]:
265
+ unknown = [n for n in args.names if n not in KNOWN_KEYS]
266
+ if unknown:
267
+ print(
268
+ f"Not read by any engine: {', '.join(unknown)}; known keys: {', '.join(KNOWN_KEYS)}."
269
+ )
270
+ return 2
271
+ for name in args.names or KNOWN_KEYS:
260
272
  print(f"{name}: {key_source(name) or 'not set'}")
261
273
  return 0
262
274
 
@@ -85,8 +85,12 @@ def _load(home: Path, today: date) -> dict[str, Any]:
85
85
  calls: Counter = Counter() # true totals; the latency deques are capped
86
86
  recent: deque = deque(maxlen=MAX_RECENT)
87
87
  cost, total, false_holds, library, loops, stops = 0.0, 0, 0, 0, 0, 0
88
- for n, r in _iter_jsonl(home / "guard.jsonl"):
89
- r["id"] = record_id(r, n)
88
+ for n, raw in _iter_jsonl(home / "guard.jsonl"):
89
+ try:
90
+ r = _clean(raw)
91
+ r["id"] = record_id(r, n)
92
+ except Exception: # noqa: BLE001, S112 -- one unreadable record never breaks the page
93
+ continue
90
94
  r["source"] = r.get("source") or "guard"
91
95
  r["false_alarm"] = r["id"] in false_alarms
92
96
  cost += r.get("cost_usd") or 0
@@ -118,7 +122,8 @@ def _load(home: Path, today: date) -> dict[str, Any]:
118
122
  "outcomes": dict(outcomes),
119
123
  "holds_per_1000": round(1000 * outcomes["hold"] / total, 1) if total else None,
120
124
  "false_alarms": false_holds,
121
- "cost_usd": round(cost, 6),
125
+ # Individually finite costs can still sum past float range.
126
+ "cost_usd": round(cost, 6) if math.isfinite(cost) else None,
122
127
  "engines": {
123
128
  name: {
124
129
  "calls": calls[name],
@@ -132,6 +137,89 @@ def _load(home: Path, today: date) -> dict[str, Any]:
132
137
  return {"summary": summary, "recent": list(recent)[::-1]}
133
138
 
134
139
 
140
+ STR_FIELDS = (
141
+ "id",
142
+ "ts",
143
+ "session",
144
+ "source",
145
+ "tool",
146
+ "subject",
147
+ "outcome",
148
+ "layer",
149
+ "rule",
150
+ "reason",
151
+ "engine",
152
+ "error",
153
+ "call",
154
+ "batch",
155
+ )
156
+ NUM_FIELDS = ("cost_usd", "latency_ms")
157
+
158
+
159
+ def _finite(value: Any) -> float | None:
160
+ """A finite int/float as float; anything else (str, bool, NaN, inf) is None."""
161
+ if isinstance(value, bool) or not isinstance(value, int | float):
162
+ return None
163
+ try:
164
+ number = float(value) # huge JSON ints overflow here: an invalid field
165
+ except OverflowError:
166
+ return None
167
+ return number if math.isfinite(number) else None
168
+
169
+
170
+ MAX_DEPTH = 8 # deeper than any field the page reads
171
+
172
+
173
+ def _scrub(value: Any, depth: int = 0) -> Any:
174
+ """NaN/Infinity anywhere (even in fields the page doesn't use) become None.
175
+ Nesting past MAX_DEPTH is cut to None, so a pathological unrelated field
176
+ can't exhaust the stack and cost the record."""
177
+ if isinstance(value, float) and not math.isfinite(value):
178
+ return None
179
+ if isinstance(value, dict | list) and depth >= MAX_DEPTH:
180
+ return None
181
+ if isinstance(value, dict):
182
+ return {k: _scrub(v, depth + 1) for k, v in value.items()}
183
+ if isinstance(value, list):
184
+ return [_scrub(v, depth + 1) for v in value]
185
+ return value
186
+
187
+
188
+ def _clean(r: dict[str, Any]) -> dict[str, Any]:
189
+ """The log is ours but may be edited, truncated or written by an older
190
+ version: coerce every field the page uses to its expected type, so no
191
+ value can crash the summary or make the JSON invalid (NaN)."""
192
+ out = _scrub(dict(r))
193
+ for key in STR_FIELDS:
194
+ if key in out and not isinstance(out[key], str):
195
+ out[key] = None
196
+ for key in NUM_FIELDS:
197
+ if key in out:
198
+ out[key] = _finite(out[key])
199
+ if not isinstance(out.get("p"), dict):
200
+ out["p"] = {}
201
+ else:
202
+ out["p"] = {
203
+ k: v
204
+ for k, v in out["p"].items()
205
+ if isinstance(k, str) and _finite(v) is not None
206
+ }
207
+ if "calls" in out and not isinstance(out["calls"], list):
208
+ # Not a calls list at all: treat the record as having none, so the
209
+ # legacy fallback still counts its engine.
210
+ del out["calls"]
211
+ if "calls" in out:
212
+ # Calls are echoed back in "recent" too: keep only well-formed ones
213
+ # with a finite (or absent) latency.
214
+ calls = out["calls"]
215
+ out["calls"] = [
216
+ {**c, "latency_ms": _finite(c.get("latency_ms"))}
217
+ for c in calls
218
+ if isinstance(c, dict) and isinstance(c.get("engine"), str)
219
+ ]
220
+ return out
221
+
222
+
135
223
  def _count_calls(r: dict[str, Any], calls: Counter, by_engine) -> None:
136
224
  """Engine metrics come from the calls each record reports, each with its
137
225
  own latency: nothing is inferred. A batch writes its calls on one record
@@ -143,14 +231,17 @@ def _count_calls(r: dict[str, Any], calls: Counter, by_engine) -> None:
143
231
  for c in reported:
144
232
  if isinstance(c, dict) and isinstance(c.get("engine"), str):
145
233
  calls[c["engine"]] += 1
146
- if isinstance(c.get("latency_ms"), int | float):
147
- by_engine[c["engine"]].append(float(c["latency_ms"]))
234
+ latency = _finite(c.get("latency_ms"))
235
+ if latency is not None:
236
+ by_engine[c["engine"]].append(latency)
148
237
  return
149
238
  if not r.get("engine") or r.get("error"):
150
239
  return
151
240
  if r.get("layer") == "judge":
152
241
  calls[r["engine"]] += 1
153
- by_engine[r["engine"]].append(float(r.get("latency_ms") or 0))
242
+ latency = _finite(r.get("latency_ms"))
243
+ if latency is not None: # no sample for a missing or invalid latency
244
+ by_engine[r["engine"]].append(latency)
154
245
  elif r.get("layer") == "stop" and r.get("outcome") in ("allow", "block"):
155
246
  # A Stop check from before calls were logged: it asked its engine
156
247
  # once, but recorded no latency, so only the call is counted.
@@ -44,7 +44,10 @@ def make_handler(home: Path, token: str, port: int) -> type[BaseHTTPRequestHandl
44
44
  self.wfile.write(body)
45
45
 
46
46
  def _json(self, status: int, obj: object) -> None:
47
- self._send(status, json.dumps(obj).encode(), "application/json")
47
+ # allow_nan=False: bare NaN isn't JSON and would break the page.
48
+ self._send(
49
+ status, json.dumps(obj, allow_nan=False).encode(), "application/json"
50
+ )
48
51
 
49
52
  def do_GET(self) -> None:
50
53
  if not self._host_ok():
@@ -94,4 +97,9 @@ def make_handler(home: Path, token: str, port: int) -> type[BaseHTTPRequestHandl
94
97
  def serve(home: Path, port: int = 8765) -> ThreadingHTTPServer:
95
98
  token = secrets.token_urlsafe(24)
96
99
  server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(home, token, port))
100
+ # Port 0 means "any free port": the Host allow-list must use the port
101
+ # the OS actually bound, or every request is refused.
102
+ bound = server.server_address[1]
103
+ if bound != port:
104
+ server.RequestHandlerClass = make_handler(home, token, bound)
97
105
  return server
@@ -10,6 +10,9 @@ from __future__ import annotations
10
10
 
11
11
  import os
12
12
 
13
+ # Keys an engine reads through get_key. Saving any other name to the keychain
14
+ # would store something nothing reads.
15
+ KNOWN_KEYS = ("TYPESAFE_API_KEY",)
13
16
  SERVICE = "judgetap"
14
17
  OLD_SERVICE = "snapjudge" # keys saved before the rename (#38)
15
18
 
@@ -0,0 +1,177 @@
1
+ import json
2
+ import math
3
+ import threading
4
+ import urllib.request
5
+
6
+ import pytest
7
+
8
+ from judgetap.dashboard.data import load
9
+
10
+
11
+ def write(tmp_path, rows):
12
+ (tmp_path / "guard.jsonl").write_text("\n".join(rows) + "\n")
13
+
14
+
15
+ @pytest.mark.parametrize(
16
+ "line",
17
+ [
18
+ '{"outcome":"allow","cost_usd":"0.1"}',
19
+ '{"outcome":["hold"]}',
20
+ '{"outcome":"allow","cost_usd":NaN}',
21
+ '{"outcome":"allow","layer":"judge","calls":[{"engine":"e","latency_ms":NaN}]}',
22
+ '{"outcome":"allow","layer":{"x":1},"engine":7,"source":[1],"p":"nope","calls":"x"}',
23
+ '{"outcome":"allow","latency_ms":Infinity,"layer":"judge","engine":"e"}',
24
+ ],
25
+ )
26
+ def test_odd_log_values_never_break_load(tmp_path, line):
27
+ write(tmp_path, [json.dumps({"outcome": "hold", "layer": "rules"}), line])
28
+ data = load(tmp_path)
29
+ json.dumps(data, allow_nan=False) # strict JSON: no NaN/Infinity left
30
+ assert data["summary"]["outcomes"].get("hold") == 1
31
+ assert all(isinstance(k, str) or k is None for k in data["summary"]["outcomes"])
32
+
33
+
34
+ def test_nan_latency_is_not_sampled(tmp_path):
35
+ write(
36
+ tmp_path,
37
+ [
38
+ '{"outcome":"allow","layer":"judge","calls":[{"engine":"e","latency_ms":NaN},{"engine":"e","latency_ms":5}]}'
39
+ ],
40
+ )
41
+ e = load(tmp_path)["summary"]["engines"]["e"]
42
+ assert e["calls"] == 2 and e["p50_ms"] == 5 and not math.isnan(e["p50_ms"])
43
+
44
+
45
+ def test_api_data_answers_with_a_bad_line(tmp_path):
46
+ from judgetap.dashboard.server import serve
47
+
48
+ write(tmp_path, ['{"outcome":"allow","cost_usd":"0.1"}', '{"outcome":["hold"]}'])
49
+ srv = serve(tmp_path, 0)
50
+ port = srv.server_address[1]
51
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
52
+ try:
53
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/data") as res:
54
+ assert res.status == 200 and json.loads(res.read())["summary"]["total"] == 2
55
+ finally:
56
+ srv.shutdown()
57
+ srv.server_close()
58
+
59
+
60
+ def test_port_zero_uses_the_bound_port(tmp_path):
61
+ from judgetap.dashboard.server import serve
62
+
63
+ srv = serve(tmp_path, 0)
64
+ port = srv.server_address[1]
65
+ assert port != 0
66
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
67
+ try:
68
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/") as res:
69
+ assert res.status == 200
70
+ finally:
71
+ srv.shutdown()
72
+ srv.server_close()
73
+
74
+
75
+ def test_keys_set_rejects_names_no_engine_reads(monkeypatch, capsys):
76
+ from judgetap.cli import main
77
+
78
+ asked = []
79
+ monkeypatch.setattr("getpass.getpass", lambda prompt: asked.append(prompt) or "v")
80
+ assert main(["keys", "set", "OPENAI_API_KEY"]) == 2
81
+ assert not asked and "isn't read by any engine" in capsys.readouterr().out
82
+ assert main(["keys", "status", "OPENAI_API_KEY"]) == 2
83
+
84
+
85
+ def test_empty_new_env_wins_over_old(monkeypatch):
86
+ from judgetap._compat import env, env_source
87
+
88
+ monkeypatch.setenv("JUDGETAP_ENGINE", "")
89
+ monkeypatch.setenv("SNAPJUDGE_ENGINE", "llm")
90
+ assert env("ENGINE") is None and env_source("ENGINE") is None
91
+ monkeypatch.delenv("JUDGETAP_ENGINE")
92
+ assert env("ENGINE") == "llm" and env_source("ENGINE") == "$SNAPJUDGE_ENGINE"
93
+
94
+
95
+ def test_nan_in_unknown_fields_is_scrubbed(tmp_path):
96
+ write(tmp_path, ['{"outcome":"allow","extra":{"x":[NaN, -Infinity]}}'])
97
+ json.dumps(load(tmp_path), allow_nan=False)
98
+
99
+
100
+ def test_huge_int_cost_keeps_the_record(tmp_path):
101
+ import json
102
+
103
+ from judgetap.dashboard.data import load
104
+
105
+ (tmp_path / "guard.jsonl").write_text(
106
+ json.dumps({"outcome": "hold", "cost_usd": 10**400}) + "\n"
107
+ )
108
+ s = load(tmp_path)["summary"]
109
+ assert s["total"] == 1 and s["outcomes"] == {"hold": 1}
110
+
111
+
112
+ def test_invalid_legacy_latency_adds_no_sample(tmp_path):
113
+ from judgetap.dashboard.data import load
114
+
115
+ (tmp_path / "guard.jsonl").write_text(
116
+ '{"outcome":"allow","layer":"judge","engine":"e","latency_ms":NaN}\n'
117
+ )
118
+ e = load(tmp_path)["summary"]["engines"]["e"]
119
+ assert e["calls"] == 1 and e["p50_ms"] is None
120
+
121
+
122
+ def test_overflowing_cost_total_still_serves(tmp_path):
123
+ import json
124
+ import socket
125
+ import threading
126
+ import urllib.request
127
+ from http.server import ThreadingHTTPServer
128
+
129
+ from judgetap.dashboard.server import make_handler
130
+
131
+ (tmp_path / "guard.jsonl").write_text(
132
+ "\n".join(json.dumps({"outcome": "allow", "cost_usd": 1e308}) for _ in range(2))
133
+ + "\n"
134
+ )
135
+ with socket.socket() as sock:
136
+ sock.bind(("127.0.0.1", 0))
137
+ port = sock.getsockname()[1]
138
+ srv = ThreadingHTTPServer(("127.0.0.1", port), make_handler(tmp_path, "tok", port))
139
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
140
+ try:
141
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/data") as res:
142
+ assert res.status == 200
143
+ assert json.loads(res.read())["summary"]["cost_usd"] is None
144
+ finally:
145
+ srv.shutdown()
146
+ srv.server_close()
147
+
148
+
149
+ def test_malformed_calls_field_keeps_legacy_fallback(tmp_path):
150
+ import json
151
+
152
+ from judgetap.dashboard.data import load
153
+
154
+ (tmp_path / "guard.jsonl").write_text(
155
+ json.dumps(
156
+ {
157
+ "id": "a",
158
+ "outcome": "allow",
159
+ "layer": "judge",
160
+ "engine": "e",
161
+ "latency_ms": 12,
162
+ "calls": "x",
163
+ }
164
+ )
165
+ + "\n"
166
+ )
167
+ e = load(tmp_path)["summary"]["engines"]["e"]
168
+ assert e["calls"] == 1 and e["p50_ms"] == 12
169
+
170
+
171
+ def test_deeply_nested_extra_field_does_not_drop_the_record(tmp_path):
172
+ from judgetap.dashboard.data import load
173
+
174
+ deep = "[" * 5000 + "]" * 5000
175
+ line = '{"id": "a", "outcome": "hold", "layer": "rules", "extra": ' + deep + "}"
176
+ (tmp_path / "guard.jsonl").write_text(line + "\n")
177
+ assert load(tmp_path)["summary"]["total"] == 1
File without changes
File without changes
File without changes