Skip to content

Commit d1a3642

Browse files
fix(cli): reuse structured ErrorCode in export CLI
1 parent baee51a commit d1a3642

6 files changed

Lines changed: 244 additions & 28 deletions

File tree

api/export_api.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,13 @@ def bulk_export() -> FlaskReturn:
146146

147147
buf = io.BytesIO()
148148

149-
def _on_export_error(sid: str, exc: Exception) -> None:
150-
current_app.logger.warning("Failed to export %s: %s", sid[:10], exc)
149+
def _on_export_error(failure: ExportFailure) -> None:
150+
current_app.logger.warning(
151+
"Failed to export %s: %s — %s",
152+
failure.session_id[:10],
153+
failure.code.value,
154+
failure.message,
155+
)
151156

152157
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
153158
result = run_bulk_export(

scripts/export.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,18 @@
3131
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
3232
sys.path.insert(0, REPO_ROOT)
3333

34+
from models.error_codes import ErrorCode
3435
from utils.exclusion_rules import load_rules, resolve_exclusion_rules_path
3536
from utils.export_engine import (
3637
BulkExportResult,
38+
ExportFailure,
3739
ExportFormat,
3840
NoopSink,
3941
SinceMode,
4042
ZipSink,
43+
bulk_export_exit_code,
44+
dominant_failure_code,
45+
failure_message_for_code,
4146
run_bulk_export,
4247
serialize_manifest_jsonl,
4348
)
@@ -177,7 +182,7 @@ def cmd_list(args):
177182
project_filter = getattr(args, "project", None)
178183

179184
if not os.path.isdir(base_dir):
180-
_die(f"Claude Code projects directory not found: {base_dir}")
185+
_die(ErrorCode.INTERNAL_ERROR, detail=f"Claude Code projects directory not found: {base_dir}")
181186

182187
projects = list_projects(base_dir)
183188
if project_filter:
@@ -236,7 +241,7 @@ def cmd_stats(args):
236241
fmt = getattr(args, "format", "text") or "text"
237242

238243
if not os.path.isdir(base_dir):
239-
_die(f"Claude Code projects directory not found: {base_dir}")
244+
_die(ErrorCode.INTERNAL_ERROR, detail=f"Claude Code projects directory not found: {base_dir}")
240245

241246
if session_id:
242247
_session_stats(session_id, base_dir, fmt)
@@ -248,7 +253,7 @@ def _session_stats(session_id: str, base_dir: str, fmt: str):
248253
"""Detailed breakdown for one session: tokens, files, commands, cost."""
249254
filepath = _find_session(session_id, base_dir)
250255
if not filepath:
251-
_die(f"Session not found: {session_id}")
256+
_die(ErrorCode.SESSION_NOT_FOUND, detail=session_id)
252257

253258
session = parse_session(filepath)
254259
stats = compute_stats(session)
@@ -406,22 +411,27 @@ def _aggregate_stats(base_dir: str, project_filter: str, fmt: str):
406411

407412

408413
def _exit_bulk_export(result: BulkExportResult) -> None:
409-
"""Map bulk-export counts to process exit code (CLI wrapper only).
414+
"""Map structured bulk-export failures to process exit code (CLI wrapper only).
410415
411416
Prints a summary to stderr on any failure, stdout on clean success.
412417
Raises SystemExit(1) for total failure, SystemExit(2) for partial.
413418
"""
414419
n = result.exported_session_count
415-
k = result.failure_count
420+
failures = result.failures
421+
k = len(failures)
416422
# "attempted" = exported + failed; excludes untitled/excluded/mtime-skipped
417423
m = n + k
418424
if n > 0 or k > 0:
419425
dest = sys.stderr if k > 0 else sys.stdout
420426
print(f"Exported {n} of {m} sessions ({k} failed)", file=dest)
421-
if n == 0 and k > 0: # total failure
422-
sys.exit(1)
423-
elif k > 0: # partial failure
424-
sys.exit(2)
427+
exit_code = bulk_export_exit_code(result)
428+
if exit_code != 0:
429+
code = dominant_failure_code(failures)
430+
print(
431+
f" {code.value}: {failure_message_for_code(code)}",
432+
file=sys.stderr,
433+
)
434+
sys.exit(exit_code)
425435

426436

427437
def cmd_export(args):
@@ -436,7 +446,7 @@ def cmd_export(args):
436446
exclusion_rules_path = getattr(args, "exclude_rules", None)
437447

438448
if not os.path.isdir(base_dir):
439-
_die(f"Claude Code projects directory not found: {base_dir}")
449+
_die(ErrorCode.INTERNAL_ERROR, detail=f"Claude Code projects directory not found: {base_dir}")
440450

441451
rules = load_rules(resolve_exclusion_rules_path(exclusion_rules_path))
442452

@@ -447,7 +457,7 @@ def cmd_export(args):
447457
if session_filter:
448458
filepath = _find_session(session_filter, base_dir)
449459
if not filepath:
450-
_die(f"Session not found: {session_filter}")
460+
_die(ErrorCode.SESSION_NOT_FOUND, detail=session_filter)
451461
session = parse_session(filepath)
452462
stats = compute_stats(session)
453463
_export_single(session, stats, fmt, out_dir)
@@ -465,8 +475,12 @@ def cmd_export(args):
465475

466476
skipped_mtime_unchanged = 0
467477

468-
def _on_export_error(sid: str, exc: Exception) -> None:
469-
print(f" Warning: failed to export {sid}: {exc}", file=sys.stderr)
478+
def _on_export_error(failure: ExportFailure) -> None:
479+
print(
480+
f" Warning: failed to export {failure.session_id}: "
481+
f"{failure.code.value}{failure.message}",
482+
file=sys.stderr,
483+
)
470484

471485
collect_sink = NoopSink()
472486
export_result = run_bulk_export(
@@ -694,8 +708,11 @@ def _find_session(session_id: str, base_dir: str) -> str | None:
694708
return matches[0]["path"]
695709
if len(matches) > 1:
696710
_die(
697-
f"Ambiguous prefix '{session_id}' matches {len(matches)} sessions:\n"
698-
+ "\n".join(f" {m['id']}" for m in matches)
711+
ErrorCode.SESSION_NOT_FOUND,
712+
detail=(
713+
f"Ambiguous prefix '{session_id}' matches {len(matches)} sessions:\n"
714+
+ "\n".join(f" {m['id']}" for m in matches)
715+
),
699716
)
700717
return None
701718

@@ -740,8 +757,10 @@ def _save_state(sessions: dict, count: int, out_dir: str):
740757
atomic_write_export_state(disk, STATE_FILE)
741758

742759

743-
def _die(msg: str):
744-
print(f"Error: {msg}", file=sys.stderr)
760+
def _die(code: ErrorCode, *, detail: str | None = None) -> None:
761+
print(f"Error: {code.value}{failure_message_for_code(code)}", file=sys.stderr)
762+
if detail:
763+
print(detail, file=sys.stderr)
745764
sys.exit(1)
746765

747766

tests/test_cli_export_exit_codes.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import scripts.export as export
1616
from models.error_codes import ErrorCode
1717
from tests.test_cli_e2e import _run_cli, _seed_base_dir
18-
from utils.export_engine import BulkExportResult, ExportFailure
18+
from utils.export_engine import BulkExportResult, ExportFailure, bulk_export_exit_code, dominant_failure_code
1919
from utils.jsonl_parser import parse_session
2020

2121
_SUMMARY_RE = re.compile(
@@ -93,6 +93,8 @@ def _parse(path: str):
9393
captured = capsys.readouterr()
9494
assert _SUMMARY_RE.search(captured.err), captured.err
9595
assert "Exported 1 of 2 sessions (1 failed)" in captured.err
96+
assert "PARSE_ERROR" in captured.err
97+
assert "simulated corrupt jsonl" not in captured.err
9698
assert len(list(out_dir.rglob("*.md"))) == 1
9799

98100

@@ -166,6 +168,7 @@ def test_since_last_early_return_exits_one_on_failure(tmp_path, monkeypatch, cap
166168
assert exc_info.value.code == 1
167169
captured = capsys.readouterr()
168170
assert "Exported 0 of 1 sessions (1 failed)" in captured.err
171+
assert "PARSE_ERROR" in captured.err
169172

170173

171174
def test_cli_export_incremental_noop_no_stderr_summary(tmp_path):
@@ -214,5 +217,44 @@ def _parse(_path: str):
214217
assert exc_info.value.code == 1
215218
captured = capsys.readouterr()
216219
assert "Exported 0 of 2 sessions (2 failed)" in captured.err
220+
assert "PARSE_ERROR" in captured.err
221+
assert "simulated corrupt jsonl" not in captured.err
217222
assert "Nothing to export." in captured.out
218223
assert list(out_dir.rglob("*.md")) == []
224+
225+
226+
@pytest.mark.parametrize(
227+
"result,expected_exit",
228+
[
229+
(BulkExportResult(), 0),
230+
(
231+
BulkExportResult(
232+
exported_session_count=2,
233+
failures=[
234+
ExportFailure("a", "Failed to parse session", ErrorCode.PARSE_ERROR),
235+
],
236+
),
237+
2,
238+
),
239+
(
240+
BulkExportResult(
241+
failures=[
242+
ExportFailure("a", "Failed to parse session", ErrorCode.PARSE_ERROR),
243+
ExportFailure("b", "Failed to export session", ErrorCode.INTERNAL_ERROR),
244+
],
245+
),
246+
1,
247+
),
248+
],
249+
)
250+
def test_bulk_export_exit_code_mapping(result: BulkExportResult, expected_exit: int) -> None:
251+
assert bulk_export_exit_code(result) == expected_exit
252+
253+
254+
def test_dominant_failure_code_picks_most_common() -> None:
255+
failures = [
256+
ExportFailure("a", "Failed to parse session", ErrorCode.PARSE_ERROR),
257+
ExportFailure("b", "Failed to parse session", ErrorCode.PARSE_ERROR),
258+
ExportFailure("c", "Failed to export session", ErrorCode.INTERNAL_ERROR),
259+
]
260+
assert dominant_failure_code(failures) == ErrorCode.PARSE_ERROR

tests/test_error_codes.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,21 @@
33
from __future__ import annotations
44

55
import json
6+
import sys
7+
import types
8+
from pathlib import Path
69

710
import pytest
811

912
from api.error_codes import ErrorCode
1013
from api.search import _IndexSearchOutcome
1114
from tests.conftest import assert_error_response
15+
from tests.test_cli_e2e import _run_cli
16+
17+
REPO_ROOT = Path(__file__).resolve().parent.parent
18+
sys.path.insert(0, str(REPO_ROOT))
19+
20+
import scripts.export as export_cli
1221

1322

1423
@pytest.mark.parametrize(
@@ -92,3 +101,39 @@ def _raise_live_scan_failure(*_args, **_kwargs):
92101
body_text = json.dumps(resp.get_json())
93102
assert_error_response(resp, expected_code=ErrorCode.SEARCH_INDEX_UNAVAILABLE)
94103
assert "live scan failed" not in body_text
104+
105+
106+
@pytest.mark.parametrize(
107+
"argv,code",
108+
[
109+
(["export", "--base-dir"], "INTERNAL_ERROR"),
110+
(["stats", "--base-dir"], "INTERNAL_ERROR"),
111+
],
112+
)
113+
def test_cli_missing_projects_dir_surfaces_error_code(tmp_path, argv: list[str], code: str) -> None:
114+
missing = tmp_path / "missing-claude-dir"
115+
proc = _run_cli([*argv, str(missing)])
116+
assert proc.returncode == 1
117+
assert code in proc.stderr
118+
assert "Traceback" not in proc.stderr
119+
120+
121+
def test_cli_export_session_not_found_surfaces_code(tmp_path, capsys) -> None:
122+
base = tmp_path / "projects"
123+
base.mkdir()
124+
with pytest.raises(SystemExit) as exc_info:
125+
export_cli.cmd_export(
126+
types.SimpleNamespace(
127+
base_dir=str(base),
128+
out=str(tmp_path / "out"),
129+
since="all",
130+
no_zip=True,
131+
project=None,
132+
format="md",
133+
session="missing-session-id",
134+
exclude_rules=None,
135+
)
136+
)
137+
assert exc_info.value.code == 1
138+
captured = capsys.readouterr()
139+
assert "SESSION_NOT_FOUND" in captured.err

tests/test_tool_dispatch_sync.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
- ``utils/md_exporter.py`` — ``_render_tool_use`` if/elif branches (parsed)
66
- ``models/tool_results.py`` — ``ToolNameLiteral``
77
- ``static/js/render/registry.js`` — ``TOOL_USE_RENDERERS`` keys (parsed)
8+
9+
``result_type`` values from ``_TOOL_RESULT_DISPATCH`` builders must match:
10+
- ``static/js/render/registry.js`` — ``TOOL_RESULT_RENDERERS`` keys (parsed)
11+
- ``utils/md_exporter.py`` — ``_render_tool_result`` if/elif branches (parsed)
812
"""
913

1014
from __future__ import annotations
@@ -23,6 +27,7 @@
2327
_REPO_ROOT = Path(__file__).resolve().parents[1]
2428
_FRONTEND_REGISTRY = _REPO_ROOT / "static" / "js" / "render" / "registry.js"
2529
_MD_EXPORTER = _REPO_ROOT / "utils" / "md_exporter.py"
30+
_TOOL_DISPATCH = _REPO_ROOT / "utils" / "tool_dispatch.py"
2631
_TOOL_TYPES_MANIFEST = _REPO_ROOT / "static" / "tool_types.json"
2732

2833

@@ -67,6 +72,55 @@ def _parse_frontend_tool_use_renderers(path: Path) -> frozenset[str]:
6772
return frozenset(keys)
6873

6974

75+
def _parse_frontend_tool_result_renderers(path: Path) -> frozenset[str]:
76+
"""Extract ``TOOL_RESULT_RENDERERS`` keys (brace-depth safe)."""
77+
text = path.read_text(encoding="utf-8")
78+
marker = "export const TOOL_RESULT_RENDERERS = {"
79+
start = text.find(marker)
80+
if start == -1:
81+
msg = f"Could not find TOOL_RESULT_RENDERERS in {path}"
82+
raise ValueError(msg)
83+
i = start + len(marker)
84+
depth = 1
85+
body_start = i
86+
while i < len(text) and depth > 0:
87+
ch = text[i]
88+
if ch == "{":
89+
depth += 1
90+
elif ch == "}":
91+
depth -= 1
92+
i += 1
93+
if depth != 0:
94+
msg = f"Unbalanced braces in TOOL_RESULT_RENDERERS in {path}"
95+
raise ValueError(msg)
96+
body = text[body_start : i - 1]
97+
keys = re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE)
98+
return frozenset(keys)
99+
100+
101+
def _parse_dispatch_builder_result_types(path: Path) -> frozenset[str]:
102+
"""Extract ``result_type`` literals from tool-result dispatch builders."""
103+
text = path.read_text(encoding="utf-8")
104+
types = set(re.findall(r'result\["result_type"\]\s*=\s*"([^"]+)"', text))
105+
types.discard("unknown")
106+
return frozenset(types)
107+
108+
109+
def _parse_md_exporter_tool_result_handlers(path: Path) -> frozenset[str]:
110+
"""Extract ``result_type`` values handled by ``_render_tool_result`` branches."""
111+
text = path.read_text(encoding="utf-8")
112+
match = re.search(
113+
r"def _render_tool_result\(.*?(?=\ndef _render_system)",
114+
text,
115+
re.DOTALL,
116+
)
117+
if not match:
118+
msg = f"Could not find _render_tool_result in {path}"
119+
raise ValueError(msg)
120+
body = match.group(0)
121+
return frozenset(re.findall(r'(?:if|elif) rt == "([^"]+)"', body))
122+
123+
70124
def _parse_md_exporter_tool_use_handlers(path: Path) -> frozenset[str]:
71125
"""Extract tool names handled by ``_render_tool_use`` if/elif branches."""
72126
text = path.read_text(encoding="utf-8")
@@ -149,3 +203,32 @@ def test_frontend_registry_matches_known_tool_types() -> None:
149203

150204
def test_known_tool_types_nonempty() -> None:
151205
assert KNOWN_TOOL_TYPES
206+
207+
208+
def test_dispatch_builder_result_types_match_frontend_registry() -> None:
209+
"""``TOOL_RESULT_RENDERERS`` keys must match dispatch builder ``result_type`` values."""
210+
site = "static/js/render/registry.js (TOOL_RESULT_RENDERERS)"
211+
try:
212+
expected = _parse_dispatch_builder_result_types(_TOOL_DISPATCH)
213+
actual = _parse_frontend_tool_result_renderers(_FRONTEND_REGISTRY)
214+
except ValueError as exc:
215+
pytest.fail(f"{site}: {exc}")
216+
if actual != expected:
217+
pytest.fail(_format_set_diff(expected, actual, site))
218+
219+
220+
def test_dispatch_builder_result_types_match_md_exporter() -> None:
221+
"""``_render_tool_result`` branches must match dispatch builder ``result_type`` values."""
222+
site = "utils/md_exporter.py (_render_tool_result branches)"
223+
try:
224+
expected = _parse_dispatch_builder_result_types(_TOOL_DISPATCH)
225+
actual = _parse_md_exporter_tool_result_handlers(_MD_EXPORTER)
226+
except ValueError as exc:
227+
pytest.fail(f"{site}: {exc}")
228+
if actual != expected:
229+
pytest.fail(_format_set_diff(expected, actual, site))
230+
231+
232+
def test_dispatch_builder_result_types_nonempty() -> None:
233+
expected = _parse_dispatch_builder_result_types(_TOOL_DISPATCH)
234+
assert expected

0 commit comments

Comments
 (0)