mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
_emit_scalar quotes a frontmatter scalar with json.dumps when it holds punctuation that would change how the line reads back. _parse_scalar undid that with a bare raw[1:-1]: it stripped the quotes but never decoded the escapes. So a description containing ü was written as the escape sequence \u00fc, read back with that escape still sitting literally in the value, and re-escaped on the next save. The backslash run doubles every save, so a non-English skill description degrades into backslash noise after a few edits, and the escapes are shown verbatim in the skills list and the /skills catalog. This is not limited to non-ASCII. Any description containing a quote takes the same path, since the quote is itself what forces the quoted form. Make the two halves symmetric: emit with ensure_ascii=False, since SKILL.md is UTF-8 at both ends (skills.py reads it, atomic_write_text writes it) and the ASCII-escaped form bought nothing; and parse double-quoted scalars with json.loads, falling back to the previous literal reading when the value is not valid JSON. Files already corrupted heal one level per load. ensure_ascii=False on its own would open a smaller hole. json.dumps escapes every C0 control character but passes NEL, LINE SEPARATOR and PARAGRAPH SEPARATOR through literally, and parse_frontmatter reads one scalar per line via str.splitlines(), which breaks on all three. Re-escape those three, and add them plus the remaining splitlines characters to the set that forces a quoted scalar, so none of them can reach the file bare.
214 lines
8.5 KiB
Python
214 lines
8.5 KiB
Python
"""Regression for issue #5210 — SKILL.md frontmatter scalars must round-trip.
|
|
|
|
``_emit_scalar`` quotes a scalar with ``json.dumps`` whenever it contains
|
|
punctuation that would change how the line reads back. ``_parse_scalar`` used
|
|
to undo that with a bare ``raw[1:-1]``: it stripped the quotes but never
|
|
decoded the escapes. So ``"Pr\\u00fcfung"`` was read back as the literal text
|
|
``Pr\\u00fcfung``, and the next save escaped *that* backslash again.
|
|
|
|
The damage compounds — each save doubles the backslash run — so a German or
|
|
Japanese skill description degrades into backslash noise after a handful of
|
|
edits, and the same happens to a plain-ASCII description that merely contains
|
|
a quote character. The escapes are also shown verbatim in the skills list and
|
|
the ``/skills`` catalog.
|
|
|
|
The fix is symmetric: emit with ``ensure_ascii=False`` (SKILL.md is UTF-8 at
|
|
both ends) and parse double-quoted scalars with ``json.loads``.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from services.memory.skill_format import (
|
|
Skill,
|
|
_emit_scalar,
|
|
_parse_scalar,
|
|
parse_frontmatter,
|
|
)
|
|
from services.memory.skills import SkillsManager
|
|
|
|
# Umlauts plus a comma — the comma is what forces the quoted form, which is the
|
|
# only path that was corrupted. Reported verbatim in issue #5210.
|
|
GERMAN = "Einstiegs- und Pr\u00fcfungslinie f\u00fcr AGB, Datenschutz"
|
|
JAPANESE = "\u30b9\u30ad\u30eb: \u30c6\u30b9\u30c8\u7528\u306e\u8aac\u660e"
|
|
QUOTED_ASCII = 'Use the "grep" tool, then summarise'
|
|
|
|
|
|
def _cycle(skill: Skill, times: int = 1) -> Skill:
|
|
"""Save to markdown and read it straight back, `times` times over."""
|
|
for _ in range(times):
|
|
skill = Skill.from_markdown(skill.to_markdown())
|
|
return skill
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The reported corruption
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"description",
|
|
[GERMAN, JAPANESE, QUOTED_ASCII],
|
|
ids=["german", "japanese", "quoted-ascii"],
|
|
)
|
|
def test_description_survives_repeated_saves(description):
|
|
"""Five load/save cycles must leave the text byte-identical.
|
|
|
|
One cycle is enough to corrupt it; five is where the doubling became
|
|
obvious in the field.
|
|
"""
|
|
result = _cycle(Skill(name="demo", description=description), times=5)
|
|
assert result.description == description
|
|
|
|
|
|
def test_corruption_does_not_compound_across_saves():
|
|
"""Pin the *growth*, not just the mismatch.
|
|
|
|
The original defect was not a one-off mangling — the escaped form was
|
|
re-escaped on every save, so the value grew without bound. A regression
|
|
that reintroduced single-level mangling would still be caught by the test
|
|
above; this one catches the runaway specifically.
|
|
"""
|
|
skill = Skill(name="demo", description=GERMAN)
|
|
lengths = []
|
|
for _ in range(5):
|
|
skill = _cycle(skill)
|
|
lengths.append(len(skill.description))
|
|
assert len(set(lengths)) == 1, f"description length drifted across saves: {lengths}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# What actually lands on disk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_non_ascii_is_written_as_utf8_not_ascii_escapes():
|
|
"""SKILL.md is opened as UTF-8 at both ends, so \\uXXXX buys nothing and
|
|
only makes the file unreadable to a human editing it."""
|
|
markdown = Skill(name="demo", description=GERMAN).to_markdown()
|
|
line = next(l for l in markdown.splitlines() if l.startswith("description:"))
|
|
assert "Pr\u00fcfungslinie" in line
|
|
assert "\\u00fc" not in line
|
|
|
|
|
|
def test_quoted_scalar_is_valid_json():
|
|
"""The emitted form is what the parser now feeds to json.loads, so the two
|
|
halves cannot drift apart without this failing."""
|
|
emitted = _emit_scalar(QUOTED_ASCII)
|
|
assert json.loads(emitted) == QUOTED_ASCII
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Existing files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_legacy_ascii_escaped_file_is_read_correctly():
|
|
"""Files already written by the old emitter hold real JSON escapes, so the
|
|
new parser recovers the intended text instead of the escape source."""
|
|
markdown = '---\nname: demo\ndescription: "Pr\\u00fcfung, x"\n---\n\n'
|
|
assert Skill.from_markdown(markdown).description == "Pr\u00fcfung, x"
|
|
|
|
|
|
def test_already_corrupted_file_heals_one_level_per_load():
|
|
"""A file that took one round of damage carries a doubled backslash. That
|
|
is still valid JSON, so reading it yields the single-backslash form and the
|
|
value stops degrading."""
|
|
markdown = '---\nname: demo\ndescription: "Pr\\\\u00fcfung, x"\n---\n\n'
|
|
once = Skill.from_markdown(markdown)
|
|
assert once.description == "Pr\\u00fcfung, x"
|
|
# And it is now stable rather than growing on every subsequent save.
|
|
assert _cycle(once, times=3).description == "Pr\\u00fcfung, x"
|
|
|
|
|
|
def test_non_json_escape_falls_back_to_literal_reading():
|
|
"""A hand-written frontmatter value can hold escapes JSON rejects (a bare
|
|
Windows path is the common one). Those must keep their previous literal
|
|
reading rather than raising."""
|
|
assert _parse_scalar('"C:\\Users\\demo"') == "C:\\Users\\demo"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unchanged behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_plain_scalars_are_still_emitted_bare():
|
|
"""Only values needing quotes get them — the common case must not suddenly
|
|
start quoting, which would churn every SKILL.md on disk."""
|
|
assert _emit_scalar("open-pr-from-branch") == "open-pr-from-branch"
|
|
assert _emit_scalar("1.0.0") == "1.0.0"
|
|
assert _emit_scalar(True) == "true"
|
|
assert _emit_scalar(None) == "null"
|
|
assert _emit_scalar(0.8) == "0.8"
|
|
|
|
|
|
def test_single_quoted_scalar_keeps_literal_reading():
|
|
"""Only double-quoted scalars are JSON. Single-quoted ones are read the way
|
|
they always were."""
|
|
assert _parse_scalar("'plain, text'") == "plain, text"
|
|
|
|
|
|
def test_lists_round_trip_with_non_ascii_entries():
|
|
skill = Skill(name="demo", tags=["b\u00fcro", "recht, steuern"])
|
|
assert _cycle(skill, times=3).tags == ["b\u00fcro", "recht, steuern"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line-break characters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sep",
|
|
["\u2028", "\u2029", "\x85", "\r", "\v", "\f", "\x1c"],
|
|
ids=["ls", "ps", "nel", "cr", "vt", "ff", "fs"],
|
|
)
|
|
def test_line_break_characters_do_not_split_the_frontmatter(sep):
|
|
"""parse_frontmatter() reads one scalar per line via str.splitlines(),
|
|
which breaks on far more than \\n. Any of these landing unescaped in the
|
|
file would silently truncate the value and shift the remainder into a
|
|
bogus key.
|
|
|
|
json.dumps covers the C0 ones, but with ensure_ascii=False it passes NEL,
|
|
LINE SEPARATOR and PARAGRAPH SEPARATOR through as themselves — so those
|
|
three are re-escaped explicitly.
|
|
"""
|
|
description = f"before{sep}after, x"
|
|
markdown = Skill(name="demo", description=description).to_markdown()
|
|
|
|
frontmatter_text = markdown.split("---")[1]
|
|
assert len(frontmatter_text.strip().splitlines()) == len(
|
|
[l for l in frontmatter_text.strip().split("\n") if l.strip()]
|
|
), "a scalar leaked a line break into the frontmatter"
|
|
|
|
fm, _body = parse_frontmatter(markdown)
|
|
assert fm["description"] == description
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End to end, through real files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_description_survives_real_save_load_cycles_on_disk(tmp_path):
|
|
"""The unit tests above go straight through to_markdown/from_markdown.
|
|
This drives the same path the app does — SkillsManager writing UTF-8 files
|
|
with atomic_write_text and reading them back — because the encoding used at
|
|
either end is part of the fix.
|
|
"""
|
|
manager = SkillsManager(str(tmp_path))
|
|
manager.add_skill(name="agb-pruefung", description=GERMAN, category="general")
|
|
|
|
for _ in range(4):
|
|
assert manager.update_skill("agb-pruefung", {"status": "published"})
|
|
|
|
stored = [s for s in manager.load_all() if s["name"] == "agb-pruefung"]
|
|
assert len(stored) == 1
|
|
assert stored[0]["description"] == GERMAN
|
|
|
|
on_disk = (tmp_path / "skills" / "general" / "agb-pruefung" / "SKILL.md").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
assert GERMAN in on_disk
|