diff --git a/.gitignore b/.gitignore index 27e8dfb0..58d14002 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ tests/skill_engine/* !tests/skill_engine/test_evolver_length_recovery.py !tests/skill_engine/test_evolution_retry_idempotency.py !tests/skill_engine/test_analyzer_length_recovery.py +!tests/skill_engine/test_set_frontmatter_multiline.py !tests/skill_engine/decision/ tests/skill_engine/decision/* !tests/skill_engine/decision/test_analysis_adapter.py diff --git a/openspace/skill_engine/skill_utils.py b/openspace/skill_engine/skill_utils.py index 555a2cfe..485d986d 100644 --- a/openspace/skill_engine/skill_utils.py +++ b/openspace/skill_engine/skill_utils.py @@ -58,10 +58,16 @@ def is_skill_safe(flags: List[str]) -> bool: def _yaml_quote(value: str) -> str: """Quote a YAML scalar value if it contains special characters.""" - if not value or not _YAML_NEEDS_QUOTE_RE.search(value): + if not value: return value - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' + if "\n" in value or _YAML_NEEDS_QUOTE_RE.search(value): + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + ) + return f'"{escaped}"' + return value def _yaml_unquote(value: str) -> str: @@ -71,7 +77,12 @@ def _yaml_unquote(value: str) -> str: (value[0] == "'" and value[-1] == "'"): inner = value[1:-1] if value[0] == '"': - inner = inner.replace('\\"', '"').replace("\\\\", "\\") + inner = ( + inner.replace("\\\\", "\0") + .replace('\\"', '"') + .replace("\\n", "\n") + .replace("\0", "\\") + ) return inner return value @@ -147,10 +158,17 @@ def set_frontmatter_field(content: str, field_name: str, value: str) -> str: new_line = f"{field_name}: {quoted}" found = False new_lines = [] + skip_block = False for line in fm_text.split("\n"): + if skip_block: + if line.startswith((" ", "\t")) or line.strip() == "": + continue + skip_block = False if ":" in line and line.split(":", 1)[0].strip() == field_name: new_lines.append(new_line) found = True + # Drop old block-scalar / folded continuation lines under this key. + skip_block = True else: new_lines.append(line) if not found: diff --git a/tests/skill_engine/test_set_frontmatter_multiline.py b/tests/skill_engine/test_set_frontmatter_multiline.py new file mode 100644 index 00000000..3af74b7a --- /dev/null +++ b/tests/skill_engine/test_set_frontmatter_multiline.py @@ -0,0 +1,38 @@ +"""Regression: multiline frontmatter values must round-trip via parse_frontmatter.""" + +from __future__ import annotations + +import builtins + +from openspace.skill_engine.skill_utils import ( + get_frontmatter_field, + parse_frontmatter, + set_frontmatter_field, +) + + +def test_set_frontmatter_field_preserves_multiline_description(): + out = set_frontmatter_field("---\nname: x\n---\n", "description", "line1\nline2") + assert parse_frontmatter(out)["description"] == "line1\nline2" + + +def test_set_frontmatter_field_replaces_block_scalar_without_orphan_lines(): + src = "---\nname: x\ndescription: |\n old1\n old2\n---\nbody\n" + out = set_frontmatter_field(src, "description", "new1\nnew2") + assert parse_frontmatter(out)["description"] == "new1\nnew2" + assert "old1" not in out + assert "old2" not in out + + +def test_multiline_round_trip_without_pyyaml(monkeypatch): + real_import = builtins.__import__ + + def no_yaml(name, *args, **kwargs): + if name == "yaml" or name.startswith("yaml."): + raise ImportError("forced missing yaml") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_yaml) + out = set_frontmatter_field("---\nname: x\n---\n", "description", "line1\nline2") + assert get_frontmatter_field(out, "description") == "line1\nline2" + assert parse_frontmatter(out)["description"] == "line1\nline2"