Skip to content

Commit 6e3ae48

Browse files
fix(parser,test): harden token arithmetic and wire fuzz CI profile
Add _safe_int() so non-numeric usage fields coerce to 0 instead of raising TypeError during token accumulation; apply to all token fields. Drop max_examples/deadline from the test decorator so the conftest ci/dev profile actually governs fuzz runtime. Inline _fuzz_jsonl_path, tighten truncation model and unknown-type assertion, simplify ALLOWED_EXCEPTIONS, and note macOS in the CONTRIBUTING PR checklist.
1 parent bdfb806 commit 6e3ae48

4 files changed

Lines changed: 68 additions & 37 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ npm run test:coverage # optional
112112
- [ ] `ruff check .` and `ruff format --check .` green locally
113113
- [ ] `pytest -q` green locally
114114
- [ ] `npm test` green if JS changed
115-
- [ ] CI jobs green (`lint-and-audit`, `pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows; `mypy`, `prod-install-smoke` on Ubuntu)
115+
- [ ] CI jobs green (`lint-and-audit`, `pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows + macOS; `mypy`, `prod-install-smoke` on Ubuntu)
116116
- [ ] PR description includes a **Test plan** section
117117
- [ ] API changes update [`docs/api-reference.md`](docs/api-reference.md) if behavior or errors change
118118

tests/conftest.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212

1313
from app import create_app
1414

15-
if os.environ.get("CI"):
16-
settings.register_profile("ci", max_examples=100, deadline=None)
17-
settings.load_profile("ci")
15+
# Hypothesis profiles drive fuzz example counts/deadlines (deadline disabled to
16+
# avoid timing flakiness on slow/CI runners). CI runs fewer examples for speed.
17+
settings.register_profile("dev", max_examples=200, deadline=None)
18+
settings.register_profile("ci", max_examples=100, deadline=None)
19+
settings.load_profile("ci" if os.environ.get("CI") else "dev")
1820

1921
FIXTURES = Path(__file__).parent / "fixtures"
2022

tests/test_parser_fuzz.py

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,21 @@
1313

1414
from utils.jsonl_parser import parse_session
1515

16-
FUZZ_SETTINGS = settings(
17-
max_examples=200,
18-
deadline=5000,
19-
suppress_health_check=[HealthCheck.function_scoped_fixture],
20-
)
16+
# Only suppress the tmp_path health check; max_examples and deadline come from
17+
# the active Hypothesis profile (ci/dev) registered in conftest.py.
18+
FUZZ_SETTINGS = settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
2119

20+
# Structured errors that are acceptable instead of a clean parse. Empty for now —
21+
# the invariant is that parse_session never raises an unhandled exception.
2222
ALLOWED_EXCEPTIONS: tuple[type[BaseException], ...] = ()
2323

2424

25-
def _fuzz_jsonl_path(tmp_path: Path, name: str) -> Path:
26-
return tmp_path / name
27-
28-
2925
def _parse_file_without_crash(path: str) -> None:
3026
try:
3127
parse_session(path)
28+
except ALLOWED_EXCEPTIONS:
29+
return
3230
except Exception as exc:
33-
if ALLOWED_EXCEPTIONS and isinstance(exc, ALLOWED_EXCEPTIONS):
34-
return
3531
raise AssertionError(f"unhandled {type(exc).__name__}: {exc}") from exc
3632

3733

@@ -151,17 +147,17 @@ def structured_entry(draw: st.DrawFn) -> dict:
151147
@given(st.lists(st.text(min_size=0, max_size=500), min_size=0, max_size=30))
152148
def test_raw_line_soup_does_not_crash(tmp_path: Path, lines: list[str]) -> None:
153149
"""Malformed JSON lines, garbage text, and empty lines."""
154-
path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "soup.jsonl"), lines)
150+
path = _write_jsonl(tmp_path / "soup.jsonl", lines)
155151
_parse_file_without_crash(path)
156152

157153

158154
@FUZZ_SETTINGS
159155
@given(st.text(min_size=1, max_size=500))
160156
def test_truncated_json_line(tmp_path: Path, prefix: str) -> None:
161-
"""Partial JSON simulating concurrent writes."""
162-
half = json.dumps(prefix)[: max(1, len(prefix) // 2)]
163-
line = '{"type": "user", "message": {"content": ' + half
164-
path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "trunc.jsonl"), [line])
157+
"""Partial JSON simulating concurrent writes (object cut mid-serialization)."""
158+
full_line = json.dumps({"type": "user", "message": {"content": prefix}})
159+
truncated = full_line[: max(1, len(full_line) // 2)]
160+
path = _write_jsonl(tmp_path / "trunc.jsonl", [truncated])
165161
_parse_file_without_crash(path)
166162

167163

@@ -170,7 +166,7 @@ def test_truncated_json_line(tmp_path: Path, prefix: str) -> None:
170166
def test_structured_entries_with_fuzzed_fields(tmp_path: Path, entries: list[dict]) -> None:
171167
"""Unknown types, missing/extra fields, wrong-typed nested values."""
172168
lines = [json.dumps(e, default=str) for e in entries]
173-
path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "structured.jsonl"), lines)
169+
path = _write_jsonl(tmp_path / "structured.jsonl", lines)
174170
_parse_file_without_crash(path)
175171

176172

@@ -182,7 +178,7 @@ def test_deep_nesting_in_message_content(tmp_path: Path, nested_values: list) ->
182178
"timestamp": "2026-06-11T00:00:00Z",
183179
"message": {"content": nested_values},
184180
}
185-
path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "nest.jsonl"), [json.dumps(entry, default=str)])
181+
path = _write_jsonl(tmp_path / "nest.jsonl", [json.dumps(entry, default=str)])
186182
_parse_file_without_crash(path)
187183

188184

@@ -195,7 +191,7 @@ def test_long_line_payload(tmp_path: Path, length: int) -> None:
195191
"timestamp": "2026-06-11T00:00:00Z",
196192
"message": {"content": [{"type": "text", "text": payload}]},
197193
}
198-
path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "long.jsonl"), [json.dumps(entry)])
194+
path = _write_jsonl(tmp_path / "long.jsonl", [json.dumps(entry)])
199195
_parse_file_without_crash(path)
200196

201197

@@ -215,7 +211,7 @@ def test_empty_lines_between_records(tmp_path: Path, texts: list[str]) -> None:
215211
)
216212
)
217213
lines.append(" ")
218-
path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "empty.jsonl"), lines)
214+
path = _write_jsonl(tmp_path / "empty.jsonl", lines)
219215
_parse_file_without_crash(path)
220216

221217

@@ -243,4 +239,27 @@ def test_unknown_record_type_is_graceful(tmp_path: Path) -> None:
243239
path = _write_jsonl(tmp_path / "unknown.jsonl", lines)
244240
session = parse_session(path)
245241
assert session["metadata"]["entry_counts"].get("totally-new-claude-record") == 1
246-
assert len(session["messages"]) >= 1
242+
# Unknown type produces no message; only the valid user line does.
243+
assert len(session["messages"]) == 1
244+
245+
246+
def test_non_numeric_usage_tokens_do_not_crash(tmp_path: Path) -> None:
247+
"""Non-numeric usage fields must coerce to 0, not raise TypeError on +=."""
248+
entry = {
249+
"type": "assistant",
250+
"timestamp": "2026-06-11T00:00:00Z",
251+
"message": {
252+
"model": "claude-test",
253+
"content": [{"type": "text", "text": "hi"}],
254+
"usage": {
255+
"input_tokens": "five",
256+
"output_tokens": ["not", "a", "number"],
257+
"cache_creation": {"ephemeral_5m_input_tokens": "lots"},
258+
},
259+
},
260+
}
261+
path = _write_jsonl(tmp_path / "bad_usage.jsonl", [json.dumps(entry)])
262+
session = parse_session(path)
263+
assert session["metadata"]["total_input_tokens"] == 0
264+
assert session["metadata"]["total_output_tokens"] == 0
265+
assert session["metadata"]["total_ephemeral_5m_tokens"] == 0

utils/jsonl_parser.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@
4040
]
4141

4242

43+
def _safe_int(val: Any) -> int:
44+
"""Coerce a value to int for token accounting; non-numeric input becomes 0
45+
so fuzzed/malformed usage fields never raise during arithmetic."""
46+
if isinstance(val, bool):
47+
return 0
48+
if isinstance(val, (int, float)):
49+
return int(val)
50+
return 0
51+
52+
4353
def parse_session(filepath: str) -> SessionDict:
4454
"""Main entry point. Reads every line from a .jsonl file and builds up
4555
a session dict with messages, metadata (tokens, models, tool counts),
@@ -223,19 +233,19 @@ def _process_assistant(
223233
usage = msg.get("usage", {})
224234
if not isinstance(usage, dict):
225235
usage = {}
226-
metadata["total_input_tokens"] += usage.get("input_tokens") or 0
227-
metadata["total_output_tokens"] += usage.get("output_tokens") or 0
228-
metadata["total_cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
229-
metadata["total_cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
236+
metadata["total_input_tokens"] += _safe_int(usage.get("input_tokens"))
237+
metadata["total_output_tokens"] += _safe_int(usage.get("output_tokens"))
238+
metadata["total_cache_read_tokens"] += _safe_int(usage.get("cache_read_input_tokens"))
239+
metadata["total_cache_creation_tokens"] += _safe_int(usage.get("cache_creation_input_tokens"))
230240

231241
# Extended cache metrics
232242
cache_creation = usage.get("cache_creation", {})
233243
if isinstance(cache_creation, dict):
234-
metadata["total_ephemeral_5m_tokens"] += (
235-
cache_creation.get("ephemeral_5m_input_tokens") or 0
244+
metadata["total_ephemeral_5m_tokens"] += _safe_int(
245+
cache_creation.get("ephemeral_5m_input_tokens")
236246
)
237-
metadata["total_ephemeral_1h_tokens"] += (
238-
cache_creation.get("ephemeral_1h_input_tokens") or 0
247+
metadata["total_ephemeral_1h_tokens"] += _safe_int(
248+
cache_creation.get("ephemeral_1h_input_tokens")
239249
)
240250

241251
# Service tier
@@ -292,10 +302,10 @@ def _process_assistant(
292302
"is_sidechain": entry.get("isSidechain", False),
293303
"is_api_error": entry.get("isApiErrorMessage", False),
294304
"usage": {
295-
"input_tokens": usage.get("input_tokens") or 0,
296-
"output_tokens": usage.get("output_tokens") or 0,
297-
"cache_read": usage.get("cache_read_input_tokens") or 0,
298-
"cache_creation": usage.get("cache_creation_input_tokens") or 0,
305+
"input_tokens": _safe_int(usage.get("input_tokens")),
306+
"output_tokens": _safe_int(usage.get("output_tokens")),
307+
"cache_read": _safe_int(usage.get("cache_read_input_tokens")),
308+
"cache_creation": _safe_int(usage.get("cache_creation_input_tokens")),
299309
"service_tier": usage.get("service_tier"),
300310
},
301311
}

0 commit comments

Comments
 (0)