From 52db5dc06f9b8eac7fbf395794b59bbc6d3e7706 Mon Sep 17 00:00:00 2001 From: star-med Date: Thu, 11 Jun 2026 08:11:26 +0800 Subject: [PATCH 1/6] test: add Hypothesis fuzz coverage and macos-latest CI matrix Add tests/test_parser_fuzz.py with strategies for malformed/truncated JSONL, unknown record types, missing/extra fields, deep nesting, long lines, empty lines, and null bytes. Harden parse_session against adversarial inputs found by fuzz (non-dict JSON values, unhashable type keys, non-str metadata fields). Extend CI matrix to macos-latest for pytest, integration-tests, js-tests, and lint-and-audit. Document Ubuntu + Windows + macOS CI in CONTRIBUTING. --- .github/workflows/ci.yml | 8 +- CONTRIBUTING.md | 2 +- pyproject.toml | 3 + requirements-dev.txt | 1 + tests/conftest.py | 6 + tests/test_parser_fuzz.py | 242 ++++++++++++++++++++++++++++++++++++++ utils/jsonl_parser.py | 36 +++--- 7 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 tests/test_parser_fuzz.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbd89fc..77bea2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] permissions: contents: read steps: @@ -83,7 +83,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] permissions: contents: read steps: @@ -138,7 +138,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] permissions: contents: read actions: write @@ -174,7 +174,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] permissions: contents: read steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 752eacb..c40a5db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Thanks for considering a patch. This repo is a small Flask app plus a hash-route - **Python 3.12** (matches CI) - **Node 20+** (only if you change `static/js/` or run frontend unit tests) -CI runs **`ruff check`**, **`ruff format --check`**, **`pip-audit`**, **`pytest`**, **integration tests**, and **Vitest** on **ubuntu-latest** and **windows-latest** (Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only. +CI runs **`ruff check`**, **`ruff format --check`**, **`pip-audit`**, **`pytest`**, **integration tests**, and **Vitest** on **Ubuntu, Windows, and macOS** (`ubuntu-latest`, `windows-latest`, `macos-latest`; Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only. ### Bootstrap (Windows PowerShell) diff --git a/pyproject.toml b/pyproject.toml index 5e8f63b..9fa81e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ select = ["E", "F", "W", "I"] [tool.ruff.lint.isort] combine-as-imports = true +[tool.hypothesis] +max_examples = 200 + [tool.ruff.lint.per-file-ignores] # CLI bootstrap: sys.path must be set before local imports. "scripts/export.py" = ["E402"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 20aab30..7e83784 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,4 @@ types-Flask==1.1.6 pytest-cov>=5.0 ruff>=0.9.0 pip-audit>=2.7.0 +hypothesis>=6.100.0 diff --git a/tests/conftest.py b/tests/conftest.py index 2d483b9..222747c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,14 +2,20 @@ from __future__ import annotations +import os import shutil from collections.abc import Mapping from pathlib import Path import pytest +from hypothesis import settings from app import create_app +if os.environ.get("CI"): + settings.register_profile("ci", max_examples=100, deadline=None) + settings.load_profile("ci") + FIXTURES = Path(__file__).parent / "fixtures" diff --git a/tests/test_parser_fuzz.py b/tests/test_parser_fuzz.py new file mode 100644 index 0000000..1df096d --- /dev/null +++ b/tests/test_parser_fuzz.py @@ -0,0 +1,242 @@ +"""Hypothesis fuzz tests for parse_session — adversarial JSONL must not crash.""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + +from hypothesis import given, settings, strategies as st + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from utils.jsonl_parser import parse_session + +FUZZ_SETTINGS = settings(max_examples=200, deadline=5000) + +ALLOWED_EXCEPTIONS: tuple[type[BaseException], ...] = () + + +def _fuzz_jsonl_path(name: str) -> Path: + return Path(tempfile.mkdtemp()) / name + + +def _parse_file_without_crash(path: str) -> None: + try: + parse_session(path) + except Exception as exc: + if ALLOWED_EXCEPTIONS and isinstance(exc, ALLOWED_EXCEPTIONS): + return + raise AssertionError(f"unhandled {type(exc).__name__}: {exc}") from exc + + +def _write_jsonl(path: os.PathLike[str], lines: list[str]) -> str: + path_str = str(path) + with open(path_str, "w", encoding="utf-8", errors="replace") as f: + for line in lines: + f.write(line) + if not line.endswith("\n"): + f.write("\n") + return path_str + + +# --------------------------------------------------------------------------- +# Strategy building blocks +# --------------------------------------------------------------------------- + +_RECORD_TYPES = st.sampled_from( + ["user", "assistant", "system", "progress", "totally-new-claude-record", "future-record-v99"] +) + +_json_leaf = st.one_of( + st.none(), + st.booleans(), + st.integers(), + st.floats(allow_nan=False, allow_infinity=False), + st.text(max_size=200), +) + +_json_value = st.recursive( + _json_leaf, + lambda children: st.one_of( + st.lists(children, max_size=8), + st.dictionaries(st.text(min_size=1, max_size=20), children, max_size=8), + ), + max_leaves=40, +) + +_minimal_user = { + "type": "user", + "timestamp": "2026-06-11T00:00:00Z", + "message": {"content": [{"type": "text", "text": "hello"}]}, +} + +_minimal_assistant = { + "type": "assistant", + "timestamp": "2026-06-11T00:00:01Z", + "message": { + "model": "claude-test", + "content": [{"type": "text", "text": "hi"}], + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, +} + + +@st.composite +def structured_entry(draw: st.DrawFn) -> dict: + """Fuzzed session record with optional missing/extra fields.""" + record_type = draw(_RECORD_TYPES) + base: dict = {"type": record_type} + if draw(st.booleans()): + base["timestamp"] = draw( + st.one_of( + st.text(max_size=40), + st.just("2026-06-11T00:00:00Z"), + st.integers(), + ) + ) + if record_type == "user": + entry = dict(_minimal_user) + entry.update(base) + if draw(st.booleans()): + entry.pop("message", None) + if draw(st.booleans()): + entry["message"] = draw( + st.one_of( + st.text(), + st.dictionaries(st.text(max_size=10), _json_value, max_size=6), + st.just({"content": draw(_json_value)}), + ) + ) + elif record_type == "assistant": + entry = dict(_minimal_assistant) + entry.update(base) + if draw(st.booleans()): + msg = dict(entry.get("message", {})) + if draw(st.booleans()): + msg["usage"] = draw( + st.one_of(st.text(), st.integers(), st.dictionaries(st.text(), _json_value)) + ) + if draw(st.booleans()): + msg["model"] = draw(st.one_of(st.text(), st.integers(), st.none())) + if draw(st.booleans()): + msg["content"] = draw(_json_value) + entry["message"] = msg + elif record_type == "system": + entry = {**base, "subtype": draw(st.text(max_size=30)), "content": draw(_json_value)} + elif record_type == "progress": + entry = { + **base, + "data": draw(st.dictionaries(st.text(max_size=10), _json_value, max_size=6)), + } + else: + entry = {**base, "payload": draw(_json_value)} + for _ in range(draw(st.integers(min_value=0, max_value=3))): + entry[draw(st.text(min_size=1, max_size=15))] = draw(_json_value) + return entry + + +# --------------------------------------------------------------------------- +# Fuzz strategies +# --------------------------------------------------------------------------- + + +@FUZZ_SETTINGS +@given(st.lists(st.text(min_size=0, max_size=500), min_size=0, max_size=30)) +def test_raw_line_soup_does_not_crash(lines: list[str]) -> None: + """Malformed JSON lines, garbage text, and empty lines.""" + path = _write_jsonl(_fuzz_jsonl_path("soup.jsonl"), lines) + _parse_file_without_crash(path) + + +@FUZZ_SETTINGS +@given(st.text(min_size=1, max_size=500)) +def test_truncated_json_line(prefix: str) -> None: + """Partial JSON simulating concurrent writes.""" + half = json.dumps(prefix)[: max(1, len(prefix) // 2)] + line = '{"type": "user", "message": {"content": ' + half + path = _write_jsonl(_fuzz_jsonl_path("trunc.jsonl"), [line]) + _parse_file_without_crash(path) + + +@FUZZ_SETTINGS +@given(st.lists(structured_entry(), min_size=0, max_size=15)) +def test_structured_entries_with_fuzzed_fields(entries: list[dict]) -> None: + """Unknown types, missing/extra fields, wrong-typed nested values.""" + lines = [json.dumps(e, default=str) for e in entries] + path = _write_jsonl(_fuzz_jsonl_path("structured.jsonl"), lines) + _parse_file_without_crash(path) + + +@FUZZ_SETTINGS +@given(st.lists(_json_value, min_size=1, max_size=5)) +def test_deep_nesting_in_message_content(nested_values: list) -> None: + entry = { + "type": "user", + "timestamp": "2026-06-11T00:00:00Z", + "message": {"content": nested_values}, + } + path = _write_jsonl(_fuzz_jsonl_path("nest.jsonl"), [json.dumps(entry, default=str)]) + _parse_file_without_crash(path) + + +@FUZZ_SETTINGS +@given(st.integers(min_value=10_000, max_value=50_000)) +def test_long_line_payload(length: int) -> None: + payload = "x" * length + entry = { + "type": "user", + "timestamp": "2026-06-11T00:00:00Z", + "message": {"content": [{"type": "text", "text": payload}]}, + } + path = _write_jsonl(_fuzz_jsonl_path("long.jsonl"), [json.dumps(entry)]) + _parse_file_without_crash(path) + + +@FUZZ_SETTINGS +@given(st.lists(st.text(max_size=100), min_size=1, max_size=10)) +def test_empty_lines_between_records(texts: list[str]) -> None: + lines: list[str] = [] + for text in texts: + lines.append("") + lines.append( + json.dumps( + { + "type": "user", + "timestamp": "2026-06-11T00:00:00Z", + "message": {"content": [{"type": "text", "text": text}]}, + } + ) + ) + lines.append(" ") + path = _write_jsonl(_fuzz_jsonl_path("empty.jsonl"), lines) + _parse_file_without_crash(path) + + +def test_null_bytes_in_file(tmp_path: Path) -> None: + """Binary-safe write with null bytes; parser uses errors='replace'.""" + valid = json.dumps( + { + "type": "user", + "timestamp": "2026-06-11T00:00:00Z", + "message": {"content": [{"type": "text", "text": "after null"}]}, + } + ).encode("utf-8") + blob = b"\x00garbage\x00\n" + valid + b"\n\x00" + path = tmp_path / "nulls.jsonl" + path.write_bytes(blob) + _parse_file_without_crash(str(path)) + + +def test_unknown_record_type_is_graceful(tmp_path: Path) -> None: + """Unknown type values are counted but do not crash parsing.""" + lines = [ + '{"type": "totally-new-claude-record", "timestamp": "2026-06-11T00:00:00Z", "payload": {}}', + '{"type": "user", "message": {"content": [{"type": "text", "text": "ok"}]}}', + ] + path = _write_jsonl(tmp_path / "unknown.jsonl", lines) + session = parse_session(path) + assert session["metadata"]["entry_counts"].get("totally-new-claude-record") == 1 + assert len(session["messages"]) >= 1 diff --git a/utils/jsonl_parser.py b/utils/jsonl_parser.py index b3a4539..731ff09 100644 --- a/utils/jsonl_parser.py +++ b/utils/jsonl_parser.py @@ -96,6 +96,9 @@ def parse_session(filepath: str) -> SessionDict: except json.JSONDecodeError: continue + if not isinstance(entry, dict): + continue + entry_type = entry.get("type") ts = entry.get("timestamp") # file-history-snapshot stores timestamp inside snapshot @@ -109,11 +112,10 @@ def parse_session(filepath: str) -> SessionDict: metadata["first_timestamp"] = ts metadata["last_timestamp"] = ts - # Count entry types - if entry_type: - metadata["entry_counts"][entry_type] = ( - metadata["entry_counts"].get(entry_type, 0) + 1 - ) + # Count entry types (upstream may send non-str discriminants) + if entry_type is not None: + type_key = entry_type if isinstance(entry_type, str) else str(entry_type) + metadata["entry_counts"][type_key] = metadata["entry_counts"].get(type_key, 0) + 1 # Track sidechain if entry.get("isSidechain"): @@ -135,10 +137,12 @@ def parse_session(filepath: str) -> SessionDict: metadata["files_created"] = sorted(metadata["files_created"]) # Compute wall clock time - if metadata["first_timestamp"] and metadata["last_timestamp"]: + first_ts = metadata["first_timestamp"] + last_ts = metadata["last_timestamp"] + if isinstance(first_ts, str) and isinstance(last_ts, str): try: - t0 = datetime.fromisoformat(metadata["first_timestamp"].replace("Z", "+00:00")) - t1 = datetime.fromisoformat(metadata["last_timestamp"].replace("Z", "+00:00")) + t0 = datetime.fromisoformat(first_ts.replace("Z", "+00:00")) + t1 = datetime.fromisoformat(last_ts.replace("Z", "+00:00")) metadata["session_wall_time_seconds"] = max(0, (t1 - t0).total_seconds()) except (ValueError, AttributeError): pass @@ -209,7 +213,7 @@ def _process_assistant( and tool_use calls, and accumulates token/model/tool stats.""" msg = _entry_message(entry) model = msg.get("model", "") - if model and model != "": + if isinstance(model, str) and model and model != "": metadata["models_used"].add(model) # API error tracking @@ -236,12 +240,12 @@ def _process_assistant( # Service tier tier = usage.get("service_tier") - if tier: + if isinstance(tier, str) and tier: metadata["service_tiers"].add(tier) # Stop reason tracking stop_reason = msg.get("stop_reason", "") - if stop_reason: + if isinstance(stop_reason, str) and stop_reason: metadata["stop_reasons"][stop_reason] = metadata["stop_reasons"].get(stop_reason, 0) + 1 content_parts = _normalize_content(msg.get("content", [])) @@ -256,7 +260,8 @@ def _process_assistant( elif ptype == "thinking": thinking_parts.append(part.get("thinking", "")) elif ptype == "tool_use": - tool_name = part.get("name", "unknown") + raw_name = part.get("name", "unknown") + tool_name = raw_name if isinstance(raw_name, str) else "unknown" raw_input = part.get("input", {}) safe_input = raw_input if isinstance(raw_input, dict) else {} metadata["total_tool_calls"] += 1 @@ -355,7 +360,8 @@ def _track_file_activity( ) -> None: """Look at what each tool call did and record which files got touched, what commands got run, what URLs got fetched.""" - fp = tool_input.get("file_path", "") + raw_fp = tool_input.get("file_path", "") + fp = raw_fp if isinstance(raw_fp, str) else "" if tool_name == "Read" and fp: metadata["files_read"].add(fp) elif tool_name == "Write" and fp: @@ -364,9 +370,9 @@ def _track_file_activity( metadata["files_written"].add(fp) elif tool_name == "Bash": cmd = tool_input.get("command", "") - if cmd: + if isinstance(cmd, str) and cmd: metadata["bash_commands"].append(cmd) elif tool_name in ("WebFetch", "WebSearch"): url_or_query = tool_input.get("url") or tool_input.get("query", "") - if url_or_query: + if isinstance(url_or_query, str) and url_or_query: metadata["web_fetches"].append(url_or_query) From d482cb61833d69a4707b5d167d6447381ef7d9b9 Mon Sep 17 00:00:00 2001 From: star-med Date: Thu, 11 Jun 2026 08:31:59 +0800 Subject: [PATCH 2/6] fix: macOS js-tests rollup install and fuzz tmp_path cleanup Install platform Rollup binary after npm ci; drop Linux-only optionalDep. Use pytest tmp_path in test_parser_fuzz.py instead of leaking mkdtemp dirs. --- .github/workflows/ci.yml | 15 +++++++++++++++ package-lock.json | 4 +--- package.json | 3 --- tests/test_parser_fuzz.py | 37 ++++++++++++++++++++----------------- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77bea2b..545dfa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,4 +188,19 @@ jobs: cache: npm - run: npm ci + # npm optional-deps bug: platform rollup binaries are sometimes skipped after npm ci + # (https://github.com/npm/cli/issues/4828). Install the native package explicitly. + - name: Ensure Rollup native binary + shell: bash + run: | + ROLLUP_VERSION=$(node -p "require('./node_modules/rollup/package.json').version") + case "$(node -p "process.platform + '-' + process.arch")" in + darwin-arm64) PKG="@rollup/rollup-darwin-arm64" ;; + darwin-x64) PKG="@rollup/rollup-darwin-x64" ;; + linux-x64) PKG="@rollup/rollup-linux-x64-gnu" ;; + win32-x64) PKG="@rollup/rollup-win32-x64-msvc" ;; + win32-arm64) PKG="@rollup/rollup-win32-arm64-msvc" ;; + *) echo "Unsupported Node platform: $(node -p "process.platform + '-' + process.arch")"; exit 1 ;; + esac + npm install --no-save "${PKG}@${ROLLUP_VERSION}" - run: npm test diff --git a/package-lock.json b/package-lock.json index f2ecedd..e9d2a94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,6 @@ "jsdom": "^26.1.0", "marked": "^12.0.1", "vitest": "^3.2.4" - }, - "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "4.60.4" } }, "node_modules/@ampproject/remapping": { @@ -281,6 +278,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index e6aaf52..8c0ac63 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,5 @@ "jsdom": "^26.1.0", "marked": "^12.0.1", "vitest": "^3.2.4" - }, - "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "4.60.4" } } diff --git a/tests/test_parser_fuzz.py b/tests/test_parser_fuzz.py index 1df096d..bd476ad 100644 --- a/tests/test_parser_fuzz.py +++ b/tests/test_parser_fuzz.py @@ -5,22 +5,25 @@ import json import os import sys -import tempfile from pathlib import Path -from hypothesis import given, settings, strategies as st +from hypothesis import HealthCheck, given, settings, strategies as st sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from utils.jsonl_parser import parse_session -FUZZ_SETTINGS = settings(max_examples=200, deadline=5000) +FUZZ_SETTINGS = settings( + max_examples=200, + deadline=5000, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) ALLOWED_EXCEPTIONS: tuple[type[BaseException], ...] = () -def _fuzz_jsonl_path(name: str) -> Path: - return Path(tempfile.mkdtemp()) / name +def _fuzz_jsonl_path(tmp_path: Path, name: str) -> Path: + return tmp_path / name def _parse_file_without_crash(path: str) -> None: @@ -145,59 +148,59 @@ def structured_entry(draw: st.DrawFn) -> dict: @FUZZ_SETTINGS @given(st.lists(st.text(min_size=0, max_size=500), min_size=0, max_size=30)) -def test_raw_line_soup_does_not_crash(lines: list[str]) -> None: +def test_raw_line_soup_does_not_crash(tmp_path: Path, lines: list[str]) -> None: """Malformed JSON lines, garbage text, and empty lines.""" - path = _write_jsonl(_fuzz_jsonl_path("soup.jsonl"), lines) + path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "soup.jsonl"), lines) _parse_file_without_crash(path) @FUZZ_SETTINGS @given(st.text(min_size=1, max_size=500)) -def test_truncated_json_line(prefix: str) -> None: +def test_truncated_json_line(tmp_path: Path, prefix: str) -> None: """Partial JSON simulating concurrent writes.""" half = json.dumps(prefix)[: max(1, len(prefix) // 2)] line = '{"type": "user", "message": {"content": ' + half - path = _write_jsonl(_fuzz_jsonl_path("trunc.jsonl"), [line]) + path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "trunc.jsonl"), [line]) _parse_file_without_crash(path) @FUZZ_SETTINGS @given(st.lists(structured_entry(), min_size=0, max_size=15)) -def test_structured_entries_with_fuzzed_fields(entries: list[dict]) -> None: +def test_structured_entries_with_fuzzed_fields(tmp_path: Path, entries: list[dict]) -> None: """Unknown types, missing/extra fields, wrong-typed nested values.""" lines = [json.dumps(e, default=str) for e in entries] - path = _write_jsonl(_fuzz_jsonl_path("structured.jsonl"), lines) + path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "structured.jsonl"), lines) _parse_file_without_crash(path) @FUZZ_SETTINGS @given(st.lists(_json_value, min_size=1, max_size=5)) -def test_deep_nesting_in_message_content(nested_values: list) -> None: +def test_deep_nesting_in_message_content(tmp_path: Path, nested_values: list) -> None: entry = { "type": "user", "timestamp": "2026-06-11T00:00:00Z", "message": {"content": nested_values}, } - path = _write_jsonl(_fuzz_jsonl_path("nest.jsonl"), [json.dumps(entry, default=str)]) + path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "nest.jsonl"), [json.dumps(entry, default=str)]) _parse_file_without_crash(path) @FUZZ_SETTINGS @given(st.integers(min_value=10_000, max_value=50_000)) -def test_long_line_payload(length: int) -> None: +def test_long_line_payload(tmp_path: Path, length: int) -> None: payload = "x" * length entry = { "type": "user", "timestamp": "2026-06-11T00:00:00Z", "message": {"content": [{"type": "text", "text": payload}]}, } - path = _write_jsonl(_fuzz_jsonl_path("long.jsonl"), [json.dumps(entry)]) + path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "long.jsonl"), [json.dumps(entry)]) _parse_file_without_crash(path) @FUZZ_SETTINGS @given(st.lists(st.text(max_size=100), min_size=1, max_size=10)) -def test_empty_lines_between_records(texts: list[str]) -> None: +def test_empty_lines_between_records(tmp_path: Path, texts: list[str]) -> None: lines: list[str] = [] for text in texts: lines.append("") @@ -211,7 +214,7 @@ def test_empty_lines_between_records(texts: list[str]) -> None: ) ) lines.append(" ") - path = _write_jsonl(_fuzz_jsonl_path("empty.jsonl"), lines) + path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "empty.jsonl"), lines) _parse_file_without_crash(path) From bdfb806c73738120095561dc1423187691f0c405 Mon Sep 17 00:00:00 2001 From: star-med Date: Thu, 11 Jun 2026 08:40:28 +0800 Subject: [PATCH 3/6] fix(test): guard dict() cast in structured_entry to fix mypy arg-type error --- tests/test_parser_fuzz.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_parser_fuzz.py b/tests/test_parser_fuzz.py index bd476ad..9c13349 100644 --- a/tests/test_parser_fuzz.py +++ b/tests/test_parser_fuzz.py @@ -117,7 +117,8 @@ def structured_entry(draw: st.DrawFn) -> dict: entry = dict(_minimal_assistant) entry.update(base) if draw(st.booleans()): - msg = dict(entry.get("message", {})) + msg_val = entry.get("message", {}) + msg = dict(msg_val) if isinstance(msg_val, dict) else {} if draw(st.booleans()): msg["usage"] = draw( st.one_of(st.text(), st.integers(), st.dictionaries(st.text(), _json_value)) From 6e3ae48710d8747522c19011a774f436b067fde9 Mon Sep 17 00:00:00 2001 From: star-med Date: Thu, 11 Jun 2026 17:41:21 +0800 Subject: [PATCH 4/6] 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. --- CONTRIBUTING.md | 2 +- tests/conftest.py | 8 +++-- tests/test_parser_fuzz.py | 61 +++++++++++++++++++++++++-------------- utils/jsonl_parser.py | 34 ++++++++++++++-------- 4 files changed, 68 insertions(+), 37 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c40a5db..48bc61d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,7 +112,7 @@ npm run test:coverage # optional - [ ] `ruff check .` and `ruff format --check .` green locally - [ ] `pytest -q` green locally - [ ] `npm test` green if JS changed - - [ ] CI jobs green (`lint-and-audit`, `pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows; `mypy`, `prod-install-smoke` on Ubuntu) + - [ ] CI jobs green (`lint-and-audit`, `pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows + macOS; `mypy`, `prod-install-smoke` on Ubuntu) - [ ] PR description includes a **Test plan** section - [ ] API changes update [`docs/api-reference.md`](docs/api-reference.md) if behavior or errors change diff --git a/tests/conftest.py b/tests/conftest.py index 222747c..f3e0a3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,9 +12,11 @@ from app import create_app -if os.environ.get("CI"): - settings.register_profile("ci", max_examples=100, deadline=None) - settings.load_profile("ci") +# Hypothesis profiles drive fuzz example counts/deadlines (deadline disabled to +# avoid timing flakiness on slow/CI runners). CI runs fewer examples for speed. +settings.register_profile("dev", max_examples=200, deadline=None) +settings.register_profile("ci", max_examples=100, deadline=None) +settings.load_profile("ci" if os.environ.get("CI") else "dev") FIXTURES = Path(__file__).parent / "fixtures" diff --git a/tests/test_parser_fuzz.py b/tests/test_parser_fuzz.py index 9c13349..0830026 100644 --- a/tests/test_parser_fuzz.py +++ b/tests/test_parser_fuzz.py @@ -13,25 +13,21 @@ from utils.jsonl_parser import parse_session -FUZZ_SETTINGS = settings( - max_examples=200, - deadline=5000, - suppress_health_check=[HealthCheck.function_scoped_fixture], -) +# Only suppress the tmp_path health check; max_examples and deadline come from +# the active Hypothesis profile (ci/dev) registered in conftest.py. +FUZZ_SETTINGS = settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) +# Structured errors that are acceptable instead of a clean parse. Empty for now — +# the invariant is that parse_session never raises an unhandled exception. ALLOWED_EXCEPTIONS: tuple[type[BaseException], ...] = () -def _fuzz_jsonl_path(tmp_path: Path, name: str) -> Path: - return tmp_path / name - - def _parse_file_without_crash(path: str) -> None: try: parse_session(path) + except ALLOWED_EXCEPTIONS: + return except Exception as exc: - if ALLOWED_EXCEPTIONS and isinstance(exc, ALLOWED_EXCEPTIONS): - return raise AssertionError(f"unhandled {type(exc).__name__}: {exc}") from exc @@ -151,17 +147,17 @@ def structured_entry(draw: st.DrawFn) -> dict: @given(st.lists(st.text(min_size=0, max_size=500), min_size=0, max_size=30)) def test_raw_line_soup_does_not_crash(tmp_path: Path, lines: list[str]) -> None: """Malformed JSON lines, garbage text, and empty lines.""" - path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "soup.jsonl"), lines) + path = _write_jsonl(tmp_path / "soup.jsonl", lines) _parse_file_without_crash(path) @FUZZ_SETTINGS @given(st.text(min_size=1, max_size=500)) def test_truncated_json_line(tmp_path: Path, prefix: str) -> None: - """Partial JSON simulating concurrent writes.""" - half = json.dumps(prefix)[: max(1, len(prefix) // 2)] - line = '{"type": "user", "message": {"content": ' + half - path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "trunc.jsonl"), [line]) + """Partial JSON simulating concurrent writes (object cut mid-serialization).""" + full_line = json.dumps({"type": "user", "message": {"content": prefix}}) + truncated = full_line[: max(1, len(full_line) // 2)] + path = _write_jsonl(tmp_path / "trunc.jsonl", [truncated]) _parse_file_without_crash(path) @@ -170,7 +166,7 @@ def test_truncated_json_line(tmp_path: Path, prefix: str) -> None: def test_structured_entries_with_fuzzed_fields(tmp_path: Path, entries: list[dict]) -> None: """Unknown types, missing/extra fields, wrong-typed nested values.""" lines = [json.dumps(e, default=str) for e in entries] - path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "structured.jsonl"), lines) + path = _write_jsonl(tmp_path / "structured.jsonl", lines) _parse_file_without_crash(path) @@ -182,7 +178,7 @@ def test_deep_nesting_in_message_content(tmp_path: Path, nested_values: list) -> "timestamp": "2026-06-11T00:00:00Z", "message": {"content": nested_values}, } - path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "nest.jsonl"), [json.dumps(entry, default=str)]) + path = _write_jsonl(tmp_path / "nest.jsonl", [json.dumps(entry, default=str)]) _parse_file_without_crash(path) @@ -195,7 +191,7 @@ def test_long_line_payload(tmp_path: Path, length: int) -> None: "timestamp": "2026-06-11T00:00:00Z", "message": {"content": [{"type": "text", "text": payload}]}, } - path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "long.jsonl"), [json.dumps(entry)]) + path = _write_jsonl(tmp_path / "long.jsonl", [json.dumps(entry)]) _parse_file_without_crash(path) @@ -215,7 +211,7 @@ def test_empty_lines_between_records(tmp_path: Path, texts: list[str]) -> None: ) ) lines.append(" ") - path = _write_jsonl(_fuzz_jsonl_path(tmp_path, "empty.jsonl"), lines) + path = _write_jsonl(tmp_path / "empty.jsonl", lines) _parse_file_without_crash(path) @@ -243,4 +239,27 @@ def test_unknown_record_type_is_graceful(tmp_path: Path) -> None: path = _write_jsonl(tmp_path / "unknown.jsonl", lines) session = parse_session(path) assert session["metadata"]["entry_counts"].get("totally-new-claude-record") == 1 - assert len(session["messages"]) >= 1 + # Unknown type produces no message; only the valid user line does. + assert len(session["messages"]) == 1 + + +def test_non_numeric_usage_tokens_do_not_crash(tmp_path: Path) -> None: + """Non-numeric usage fields must coerce to 0, not raise TypeError on +=.""" + entry = { + "type": "assistant", + "timestamp": "2026-06-11T00:00:00Z", + "message": { + "model": "claude-test", + "content": [{"type": "text", "text": "hi"}], + "usage": { + "input_tokens": "five", + "output_tokens": ["not", "a", "number"], + "cache_creation": {"ephemeral_5m_input_tokens": "lots"}, + }, + }, + } + path = _write_jsonl(tmp_path / "bad_usage.jsonl", [json.dumps(entry)]) + session = parse_session(path) + assert session["metadata"]["total_input_tokens"] == 0 + assert session["metadata"]["total_output_tokens"] == 0 + assert session["metadata"]["total_ephemeral_5m_tokens"] == 0 diff --git a/utils/jsonl_parser.py b/utils/jsonl_parser.py index 731ff09..bf7c14e 100644 --- a/utils/jsonl_parser.py +++ b/utils/jsonl_parser.py @@ -40,6 +40,16 @@ ] +def _safe_int(val: Any) -> int: + """Coerce a value to int for token accounting; non-numeric input becomes 0 + so fuzzed/malformed usage fields never raise during arithmetic.""" + if isinstance(val, bool): + return 0 + if isinstance(val, (int, float)): + return int(val) + return 0 + + def parse_session(filepath: str) -> SessionDict: """Main entry point. Reads every line from a .jsonl file and builds up a session dict with messages, metadata (tokens, models, tool counts), @@ -223,19 +233,19 @@ def _process_assistant( usage = msg.get("usage", {}) if not isinstance(usage, dict): usage = {} - metadata["total_input_tokens"] += usage.get("input_tokens") or 0 - metadata["total_output_tokens"] += usage.get("output_tokens") or 0 - metadata["total_cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0 - metadata["total_cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0 + metadata["total_input_tokens"] += _safe_int(usage.get("input_tokens")) + metadata["total_output_tokens"] += _safe_int(usage.get("output_tokens")) + metadata["total_cache_read_tokens"] += _safe_int(usage.get("cache_read_input_tokens")) + metadata["total_cache_creation_tokens"] += _safe_int(usage.get("cache_creation_input_tokens")) # Extended cache metrics cache_creation = usage.get("cache_creation", {}) if isinstance(cache_creation, dict): - metadata["total_ephemeral_5m_tokens"] += ( - cache_creation.get("ephemeral_5m_input_tokens") or 0 + metadata["total_ephemeral_5m_tokens"] += _safe_int( + cache_creation.get("ephemeral_5m_input_tokens") ) - metadata["total_ephemeral_1h_tokens"] += ( - cache_creation.get("ephemeral_1h_input_tokens") or 0 + metadata["total_ephemeral_1h_tokens"] += _safe_int( + cache_creation.get("ephemeral_1h_input_tokens") ) # Service tier @@ -292,10 +302,10 @@ def _process_assistant( "is_sidechain": entry.get("isSidechain", False), "is_api_error": entry.get("isApiErrorMessage", False), "usage": { - "input_tokens": usage.get("input_tokens") or 0, - "output_tokens": usage.get("output_tokens") or 0, - "cache_read": usage.get("cache_read_input_tokens") or 0, - "cache_creation": usage.get("cache_creation_input_tokens") or 0, + "input_tokens": _safe_int(usage.get("input_tokens")), + "output_tokens": _safe_int(usage.get("output_tokens")), + "cache_read": _safe_int(usage.get("cache_read_input_tokens")), + "cache_creation": _safe_int(usage.get("cache_creation_input_tokens")), "service_tier": usage.get("service_tier"), }, } From e963fcf0fd3ea2368f432044c16dcf48eb828bd3 Mon Sep 17 00:00:00 2001 From: star-med Date: Thu, 11 Jun 2026 21:16:58 +0800 Subject: [PATCH 5/6] fix(parser): coerce non-finite usage tokens and tidy review nits Guard _safe_int against NaN/Infinity (json.loads accepts these literals; int(nan)/int(inf) raise), broaden fuzz floats to cover them, and add an explicit non-finite regression test. Revert entry_counts to truthiness so empty-string types stay skipped, wrap service_tier in the message payload, and drop the no-op [tool.hypothesis] block from pyproject.toml. --- pyproject.toml | 3 --- tests/test_parser_fuzz.py | 21 ++++++++++++++++++++- utils/jsonl_parser.py | 19 ++++++++++++------- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9fa81e8..5e8f63b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,9 +27,6 @@ select = ["E", "F", "W", "I"] [tool.ruff.lint.isort] combine-as-imports = true -[tool.hypothesis] -max_examples = 200 - [tool.ruff.lint.per-file-ignores] # CLI bootstrap: sys.path must be set before local imports. "scripts/export.py" = ["E402"] diff --git a/tests/test_parser_fuzz.py b/tests/test_parser_fuzz.py index 0830026..9ffa6e7 100644 --- a/tests/test_parser_fuzz.py +++ b/tests/test_parser_fuzz.py @@ -53,7 +53,8 @@ def _write_jsonl(path: os.PathLike[str], lines: list[str]) -> str: st.none(), st.booleans(), st.integers(), - st.floats(allow_nan=False, allow_infinity=False), + # Allow NaN/Infinity: json.loads accepts these literals, so the parser must too. + st.floats(allow_nan=True, allow_infinity=True), st.text(max_size=200), ) @@ -263,3 +264,21 @@ def test_non_numeric_usage_tokens_do_not_crash(tmp_path: Path) -> None: assert session["metadata"]["total_input_tokens"] == 0 assert session["metadata"]["total_output_tokens"] == 0 assert session["metadata"]["total_ephemeral_5m_tokens"] == 0 + + +def test_non_finite_usage_tokens_do_not_crash(tmp_path: Path) -> None: + """json.loads accepts NaN/Infinity literals; int(nan)/int(inf) raise, so the + parser must coerce them to 0 rather than propagate ValueError/OverflowError.""" + # Raw literals (not valid via json.dumps of finite floats) — written directly. + line = ( + '{"type": "assistant", "message": {"usage": ' + '{"input_tokens": NaN, "output_tokens": Infinity, ' + '"cache_read_input_tokens": -Infinity, ' + '"cache_creation": {"ephemeral_5m_input_tokens": NaN}}}}' + ) + path = _write_jsonl(tmp_path / "nonfinite.jsonl", [line]) + session = parse_session(path) + assert session["metadata"]["total_input_tokens"] == 0 + assert session["metadata"]["total_output_tokens"] == 0 + assert session["metadata"]["total_cache_read_tokens"] == 0 + assert session["metadata"]["total_ephemeral_5m_tokens"] == 0 diff --git a/utils/jsonl_parser.py b/utils/jsonl_parser.py index bf7c14e..8f5e46e 100644 --- a/utils/jsonl_parser.py +++ b/utils/jsonl_parser.py @@ -2,6 +2,7 @@ actually work with -- messages, tool calls, token counts, file activity, etc.""" import json +import math import os from datetime import datetime from typing import Any @@ -41,12 +42,15 @@ def _safe_int(val: Any) -> int: - """Coerce a value to int for token accounting; non-numeric input becomes 0 - so fuzzed/malformed usage fields never raise during arithmetic.""" + """Coerce a value to int for token accounting; non-numeric or non-finite + input becomes 0 so fuzzed/malformed usage fields never raise during + arithmetic. json.loads accepts NaN/Infinity literals, so guard against them.""" if isinstance(val, bool): return 0 - if isinstance(val, (int, float)): - return int(val) + if isinstance(val, int): + return val + if isinstance(val, float): + return int(val) if math.isfinite(val) else 0 return 0 @@ -122,8 +126,9 @@ def parse_session(filepath: str) -> SessionDict: metadata["first_timestamp"] = ts metadata["last_timestamp"] = ts - # Count entry types (upstream may send non-str discriminants) - if entry_type is not None: + # Count entry types (upstream may send non-str/unhashable discriminants; + # coerce to str. Falsy types like "" are skipped, matching prior behavior). + if entry_type: type_key = entry_type if isinstance(entry_type, str) else str(entry_type) metadata["entry_counts"][type_key] = metadata["entry_counts"].get(type_key, 0) + 1 @@ -306,7 +311,7 @@ def _process_assistant( "output_tokens": _safe_int(usage.get("output_tokens")), "cache_read": _safe_int(usage.get("cache_read_input_tokens")), "cache_creation": _safe_int(usage.get("cache_creation_input_tokens")), - "service_tier": usage.get("service_tier"), + "service_tier": tier if isinstance(tier, str) else None, }, } ) From de80dfa5b3ba1659b00cc866a07504ab0daf332e Mon Sep 17 00:00:00 2001 From: star-med Date: Thu, 11 Jun 2026 21:28:22 +0800 Subject: [PATCH 6/6] fix(parser): clamp _safe_int to non-negative for token accounting Negative usage token values from adversarial JSONL must not reduce session metadata totals; apply max(0, ...) in _safe_int and add regression test. --- tests/test_parser_fuzz.py | 22 ++++++++++++++++++++++ utils/jsonl_parser.py | 10 +++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/test_parser_fuzz.py b/tests/test_parser_fuzz.py index 9ffa6e7..bde0ff1 100644 --- a/tests/test_parser_fuzz.py +++ b/tests/test_parser_fuzz.py @@ -266,6 +266,28 @@ def test_non_numeric_usage_tokens_do_not_crash(tmp_path: Path) -> None: assert session["metadata"]["total_ephemeral_5m_tokens"] == 0 +def test_negative_usage_tokens_clamp_to_zero(tmp_path: Path) -> None: + """Negative token counts must not reduce session metadata totals.""" + entry = { + "type": "assistant", + "timestamp": "2026-06-11T00:00:00Z", + "message": { + "model": "claude-test", + "content": [{"type": "text", "text": "hi"}], + "usage": { + "input_tokens": -100, + "output_tokens": -1.5, + "cache_creation": {"ephemeral_5m_input_tokens": -50}, + }, + }, + } + path = _write_jsonl(tmp_path / "negative_usage.jsonl", [json.dumps(entry)]) + session = parse_session(path) + assert session["metadata"]["total_input_tokens"] == 0 + assert session["metadata"]["total_output_tokens"] == 0 + assert session["metadata"]["total_ephemeral_5m_tokens"] == 0 + + def test_non_finite_usage_tokens_do_not_crash(tmp_path: Path) -> None: """json.loads accepts NaN/Infinity literals; int(nan)/int(inf) raise, so the parser must coerce them to 0 rather than propagate ValueError/OverflowError.""" diff --git a/utils/jsonl_parser.py b/utils/jsonl_parser.py index 8f5e46e..6186b7b 100644 --- a/utils/jsonl_parser.py +++ b/utils/jsonl_parser.py @@ -42,15 +42,15 @@ def _safe_int(val: Any) -> int: - """Coerce a value to int for token accounting; non-numeric or non-finite - input becomes 0 so fuzzed/malformed usage fields never raise during - arithmetic. json.loads accepts NaN/Infinity literals, so guard against them.""" + """Coerce a value to a non-negative int for token accounting; non-numeric, + non-finite, or negative input becomes 0 so fuzzed/malformed usage fields + never raise during arithmetic and counters cannot go below zero.""" if isinstance(val, bool): return 0 if isinstance(val, int): - return val + return max(0, val) if isinstance(val, float): - return int(val) if math.isfinite(val) else 0 + return max(0, int(val)) if math.isfinite(val) else 0 return 0