Skip to content

Commit 4345d69

Browse files
claude-code-chat-browser: YAML escape fix — handle colons, hashes, tabs, bool literals in frontmatter export (#107)
* YAML frontmatter: always-quote scalars for safe round-trip parsing Refactor frontmatter export through _session_frontmatter_dict and _append_yaml_value so all string fields are double-quoted or block-scalars. _escape_yaml handles tabs, hashes, colons, bool-like literals, and non-printable Unicode. Add round-trip and Hypothesis property tests; pin PyYAML in dev deps. * Fix CI: newline-only YAML block scalars, ruff format, types-PyYAML * YAML frontmatter: drop block scalars so all strings round-trip Block scalars cannot preserve newline-only strings on Python 3.12 where splitlines() yields ['']. Always emit double-quoted scalars. Widen test_escape_yaml_roundtrip to cover multiline text. * test: use delimiter-aware frontmatter extraction in roundtrip test Drop brittle md.split('---') assertion; _extract_frontmatter_dict already covers the same check with proper delimiter handling. * YAML frontmatter: emit models_used and service_tiers as sequences Store list fields as lists in _session_frontmatter_dict and serialize them as YAML block sequences in _append_yaml_value instead of comma-joined strings that consumers cannot parse as arrays.
1 parent 7efe474 commit 4345d69

3 files changed

Lines changed: 241 additions & 44 deletions

File tree

requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ pytest-cov>=5.0
66
ruff>=0.9.0
77
pip-audit>=2.7.0
88
hypothesis>=6.100.0
9+
PyYAML>=6.0
10+
types-PyYAML>=6.0
911
pytest-benchmark==5.2.3

tests/test_md_exporter_yaml.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""YAML frontmatter escaping and round-trip tests for md_exporter."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import sys
7+
8+
import yaml
9+
from hypothesis import given, settings, strategies as st
10+
11+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
12+
13+
from models.session import SessionDict
14+
from utils.md_exporter import (
15+
_append_yaml_value,
16+
_escape_yaml,
17+
_session_frontmatter_dict,
18+
session_to_markdown,
19+
)
20+
21+
FUZZ_SETTINGS = settings(max_examples=100)
22+
23+
24+
def _extract_frontmatter_dict(markdown: str) -> dict:
25+
lines = markdown.splitlines()
26+
if not lines or lines[0].strip() != "---":
27+
raise ValueError("missing opening frontmatter delimiter")
28+
yaml_lines: list[str] = []
29+
for line in lines[1:]:
30+
if line.strip() == "---":
31+
break
32+
yaml_lines.append(line)
33+
else:
34+
raise ValueError("missing closing frontmatter delimiter")
35+
loaded = yaml.safe_load("\n".join(yaml_lines))
36+
return loaded if isinstance(loaded, dict) else {}
37+
38+
39+
def _base_session(**overrides: object) -> SessionDict:
40+
session: SessionDict = {
41+
"session_id": "sess-001",
42+
"title": "Hello",
43+
"messages": [{"role": "user", "text": "hi"}],
44+
"metadata": {
45+
"session_id": "sess-001",
46+
"models_used": ["claude-sonnet-4-20250514"],
47+
"first_timestamp": "2026-01-02T12:00:00Z",
48+
"last_timestamp": "2026-01-02T12:30:00Z",
49+
"total_input_tokens": 120,
50+
"total_output_tokens": 45,
51+
"total_cache_read_tokens": 10,
52+
"total_tool_calls": 2,
53+
"tool_call_counts": {"Read": 2},
54+
"cwd": "/workspace",
55+
"git_branch": "main",
56+
"version": "1.0.0",
57+
"permission_mode": "default",
58+
},
59+
}
60+
if overrides:
61+
for key, value in overrides.items():
62+
if key == "metadata" and isinstance(value, dict):
63+
session["metadata"].update(value) # type: ignore[typeddict-item]
64+
else:
65+
session[key] = value # type: ignore[literal-required]
66+
return session
67+
68+
69+
class TestYamlFrontmatterRoundtrip:
70+
def test_yaml_frontmatter_roundtrip(self):
71+
session = _base_session(
72+
title="Fix: handle edge case #42",
73+
metadata={
74+
"cwd": r"C:\Users\dev\project",
75+
"git_branch": "feat#yaml",
76+
"permission_mode": "true",
77+
"stop_reasons": {"max_tokens": 1, "end_turn": 2},
78+
"tool_call_counts": {"Read": 1, "Fix: tool": 1},
79+
},
80+
)
81+
md = session_to_markdown(session)
82+
assert _extract_frontmatter_dict(md) == _session_frontmatter_dict(session)
83+
84+
def test_multiline_title_uses_quoted_scalar(self):
85+
session = _base_session(title="line one\nline two")
86+
md = session_to_markdown(session)
87+
assert 'title: "line one\\nline two"' in md.split("---")[1]
88+
assert _extract_frontmatter_dict(md)["title"] == "line one\nline two"
89+
90+
def test_tab_and_hash_in_title(self):
91+
session = _base_session(title="tab\there # not a comment")
92+
md = session_to_markdown(session)
93+
assert _extract_frontmatter_dict(md)["title"] == "tab\there # not a comment"
94+
95+
def test_models_used_serializes_as_yaml_sequence(self):
96+
session = _base_session(
97+
metadata={"models_used": ["claude-sonnet-4", "claude-opus-4"]},
98+
)
99+
md = session_to_markdown(session)
100+
frontmatter = _extract_frontmatter_dict(md)
101+
assert frontmatter["models_used"] == ["claude-sonnet-4", "claude-opus-4"]
102+
assert "models_used:\n" in md.split("---")[1] or "models_used:" in md.split("---")[1]
103+
assert ' - "claude-sonnet-4"' in md.split("---")[1]
104+
105+
106+
@FUZZ_SETTINGS
107+
@given(st.text())
108+
def test_escape_yaml_roundtrip(s: str) -> None:
109+
"""Double-quoted scalars round-trip for arbitrary text."""
110+
loaded = yaml.safe_load(f"key: {_escape_yaml(s)}")
111+
assert loaded["key"] == s
112+
113+
114+
@FUZZ_SETTINGS
115+
@given(st.text())
116+
def test_yaml_string_field_roundtrip(s: str) -> None:
117+
"""Frontmatter string serializer round-trips arbitrary text."""
118+
lines: list[str] = []
119+
_append_yaml_value(lines, "key", s)
120+
loaded = yaml.safe_load("\n".join(lines))
121+
assert loaded["key"] == s

utils/md_exporter.py

Lines changed: 118 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Markdown export. Produces a .md with YAML frontmatter, a summary section
22
(cost, files touched, commands run), and the full conversation."""
33

4+
import re
45
from datetime import datetime
56
from typing import Any
67

@@ -25,63 +26,70 @@ def session_to_markdown(session: SessionDict, stats: SessionStatsDict | None = N
2526

2627

2728
def _build_frontmatter(session: SessionDict) -> str:
28-
meta = session["metadata"]
2929
lines = ["---"]
30-
lines.append(f'title: "{_escape_yaml(session["title"])}"')
31-
if meta["first_timestamp"]:
32-
lines.append(f"created: {meta['first_timestamp']}")
33-
if meta["last_timestamp"]:
34-
lines.append(f"updated: {meta['last_timestamp']}")
35-
lines.append(f"session_id: {session['session_id']}")
36-
if meta["models_used"]:
37-
lines.append(f"models_used: {', '.join(meta['models_used'])}")
38-
lines.append(f"total_input_tokens: {meta['total_input_tokens']}")
39-
lines.append(f"total_output_tokens: {meta['total_output_tokens']}")
40-
lines.append(f"total_cache_read_tokens: {meta['total_cache_read_tokens']}")
30+
for key, value in _session_frontmatter_dict(session).items():
31+
_append_yaml_value(lines, key, value)
32+
lines.append("---")
33+
return "\n".join(lines)
34+
35+
36+
def _session_frontmatter_dict(session: SessionDict) -> dict[str, Any]:
37+
"""Canonical frontmatter payload; used for export and round-trip tests."""
38+
meta = session["metadata"]
39+
data: dict[str, Any] = {
40+
"title": session["title"],
41+
"session_id": session["session_id"],
42+
"total_input_tokens": meta.get("total_input_tokens", 0),
43+
"total_output_tokens": meta.get("total_output_tokens", 0),
44+
"total_cache_read_tokens": meta.get("total_cache_read_tokens", 0),
45+
"total_tool_calls": meta.get("total_tool_calls", 0),
46+
"message_count": len(session["messages"]),
47+
}
48+
if meta.get("first_timestamp"):
49+
data["created"] = meta["first_timestamp"]
50+
if meta.get("last_timestamp"):
51+
data["updated"] = meta["last_timestamp"]
52+
if meta.get("models_used"):
53+
data["models_used"] = list(meta["models_used"])
4154
if meta.get("total_cache_creation_tokens", 0) > 0:
42-
lines.append(f"total_cache_creation_tokens: {meta['total_cache_creation_tokens']}")
43-
lines.append(f"total_tool_calls: {meta['total_tool_calls']}")
44-
if meta["tool_call_counts"]:
45-
lines.append("tool_call_breakdown:")
46-
for tool, count in sorted(meta["tool_call_counts"].items(), key=lambda x: -x[1]):
47-
lines.append(f" {tool}: {count}")
55+
data["total_cache_creation_tokens"] = meta["total_cache_creation_tokens"]
56+
if meta.get("tool_call_counts"):
57+
data["tool_call_breakdown"] = dict(
58+
sorted(meta["tool_call_counts"].items(), key=lambda item: -item[1])
59+
)
4860
if meta.get("stop_reasons"):
49-
lines.append("stop_reasons:")
50-
for reason, count in sorted(meta["stop_reasons"].items(), key=lambda x: -x[1]):
51-
lines.append(f" {reason}: {count}")
52-
if meta["cwd"]:
53-
lines.append(f'working_directory: "{_escape_yaml(meta["cwd"])}"')
54-
if meta["git_branch"]:
55-
lines.append(f"git_branch: {meta['git_branch']}")
56-
if meta["version"]:
57-
lines.append(f"claude_code_version: {meta['version']}")
58-
if meta["permission_mode"]:
59-
lines.append(f"permission_mode: {meta['permission_mode']}")
61+
data["stop_reasons"] = dict(sorted(meta["stop_reasons"].items(), key=lambda item: -item[1]))
62+
if meta.get("cwd"):
63+
data["working_directory"] = meta["cwd"]
64+
if meta.get("git_branch"):
65+
data["git_branch"] = meta["git_branch"]
66+
if meta.get("version"):
67+
data["claude_code_version"] = meta["version"]
68+
if meta.get("permission_mode"):
69+
data["permission_mode"] = meta["permission_mode"]
6070
if meta.get("service_tiers"):
61-
lines.append(f"service_tiers: {', '.join(meta['service_tiers'])}")
62-
lines.append(f"message_count: {len(session['messages'])}")
63-
if meta["compactions"] > 0:
64-
lines.append(f"compactions: {meta['compactions']}")
71+
data["service_tiers"] = list(meta["service_tiers"])
72+
if meta.get("compactions", 0) > 0:
73+
data["compactions"] = meta["compactions"]
6574
if meta.get("api_errors", 0) > 0:
66-
lines.append(f"api_errors: {meta['api_errors']}")
75+
data["api_errors"] = meta["api_errors"]
6776
if meta.get("sidechain_messages", 0) > 0:
68-
lines.append(f"sidechain_messages: {meta['sidechain_messages']}")
77+
data["sidechain_messages"] = meta["sidechain_messages"]
6978
wall = meta.get("session_wall_time_seconds")
7079
if wall is not None:
71-
lines.append(f"wall_clock_seconds: {int(wall)}")
80+
data["wall_clock_seconds"] = int(wall)
7281
files_r = meta.get("files_read", [])
7382
files_w = meta.get("files_written", [])
7483
files_c = meta.get("files_created", [])
7584
if files_r or files_w or files_c:
76-
lines.append(f"files_read: {len(files_r)}")
77-
lines.append(f"files_written: {len(files_w)}")
78-
lines.append(f"files_created: {len(files_c)}")
85+
data["files_read"] = len(files_r)
86+
data["files_written"] = len(files_w)
87+
data["files_created"] = len(files_c)
7988
if meta.get("bash_commands"):
80-
lines.append(f"commands_run: {len(meta['bash_commands'])}")
89+
data["commands_run"] = len(meta["bash_commands"])
8190
if meta.get("web_fetches"):
82-
lines.append(f"web_fetches: {len(meta['web_fetches'])}")
83-
lines.append("---")
84-
return "\n".join(lines)
91+
data["web_fetches"] = len(meta["web_fetches"])
92+
return data
8593

8694

8795
def _build_header(session: SessionDict) -> str:
@@ -459,8 +467,74 @@ def _format_ts(ts: str | None) -> str:
459467
return ts
460468

461469

470+
_PLAIN_YAML_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
471+
472+
473+
def _yaml_mapping_key(key: str) -> str:
474+
"""Format a nested mapping key, quoting when it is not a plain identifier."""
475+
if _PLAIN_YAML_KEY.match(key):
476+
return key
477+
return _escape_yaml(key)
478+
479+
462480
def _escape_yaml(s: str) -> str:
463-
return s.replace('"', '\\"').replace("\n", " ")
481+
"""Return a YAML double-quoted scalar for any string (including embedded newlines)."""
482+
parts: list[str] = []
483+
for ch in s:
484+
if ch == "\\":
485+
parts.append("\\\\")
486+
elif ch == '"':
487+
parts.append('\\"')
488+
elif ch == "\t":
489+
parts.append("\\t")
490+
elif ch == "\n":
491+
parts.append("\\n")
492+
elif ch == "\r":
493+
parts.append("\\r")
494+
elif not ch.isprintable():
495+
code = ord(ch)
496+
if code <= 0xFF:
497+
parts.append(f"\\x{code:02x}")
498+
elif code <= 0xFFFF:
499+
parts.append(f"\\u{code:04x}")
500+
else:
501+
parts.append(f"\\U{code:08x}")
502+
else:
503+
parts.append(ch)
504+
return f'"{"".join(parts)}"'
505+
506+
507+
def _append_yaml_value(lines: list[str], key: str, value: Any, *, indent: int = 0) -> None:
508+
"""Append one frontmatter field, quoting strings and nesting mappings safely."""
509+
prefix = " " * indent
510+
if isinstance(value, dict):
511+
lines.append(f"{prefix}{key}:")
512+
for nested_key, nested_value in value.items():
513+
_append_yaml_value(
514+
lines,
515+
_yaml_mapping_key(nested_key),
516+
nested_value,
517+
indent=indent + 1,
518+
)
519+
return
520+
if isinstance(value, list):
521+
lines.append(f"{prefix}{key}:")
522+
for item in value:
523+
if isinstance(item, str):
524+
lines.append(f"{prefix} - {_escape_yaml(item)}")
525+
elif isinstance(item, int):
526+
lines.append(f"{prefix} - {item}")
527+
else:
528+
raise TypeError(
529+
f"unsupported frontmatter sequence item type: {type(item).__name__}"
530+
)
531+
return
532+
if isinstance(value, int):
533+
lines.append(f"{prefix}{key}: {value}")
534+
return
535+
if not isinstance(value, str):
536+
raise TypeError(f"unsupported frontmatter value type: {type(value).__name__}")
537+
lines.append(f"{prefix}{key}: {_escape_yaml(value)}")
464538

465539

466540
def _truncate(s: str, max_len: int) -> str:

0 commit comments

Comments
 (0)