From 6d348254a3e91c5d6088b24ab4e45e588012e06d Mon Sep 17 00:00:00 2001 From: SA_653 <32102656+aminalavian@users.noreply.github.com> Date: Sun, 24 May 2026 16:17:02 -0700 Subject: [PATCH] fix(skill_engine): support YAML block scalars in SKILL.md frontmatter (#1) # reviewed Diff reviewed locally: 211 insertions, 17 deletions across 4 files. 13/13 regression tests pass. No new deps. Production code is skill_utils.py only; rest is tests. --- openspace/skill_engine/skill_utils.py | 128 +++++++++++++++++++++---- tests/__init__.py | 1 + tests/skill_engine/__init__.py | 1 + tests/skill_engine/test_skill_utils.py | 98 +++++++++++++++++++ 4 files changed, 211 insertions(+), 17 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/skill_engine/__init__.py create mode 100644 tests/skill_engine/test_skill_utils.py diff --git a/openspace/skill_engine/skill_utils.py b/openspace/skill_engine/skill_utils.py index 6d6fc7c..2777086 100644 --- a/openspace/skill_engine/skill_utils.py +++ b/openspace/skill_engine/skill_utils.py @@ -58,6 +58,18 @@ _YAML_NEEDS_QUOTE_RE = re.compile(r"[:\#\[\]{}&*!|>'\"%@`]") def _yaml_quote(value: str) -> str: """Quote a YAML scalar value if it contains special characters.""" + if "\n" in value: + trailing_newlines = len(value) - len(value.rstrip("\n")) + if trailing_newlines == 0: + chomping = "|-" + elif trailing_newlines == 1: + chomping = "|" + else: + chomping = "|+" + lines = value.split("\n") + if trailing_newlines and lines and lines[-1] == "": + lines = lines[:-1] + return chomping + "\n" + "\n".join(f" {line}" for line in lines) if not value or not _YAML_NEEDS_QUOTE_RE.search(value): return value escaped = value.replace("\\", "\\\\").replace('"', '\\"') @@ -72,15 +84,108 @@ def _yaml_unquote(value: str) -> str: inner = value[1:-1] if value[0] == '"': inner = inner.replace('\\"', '"').replace("\\\\", "\\") + else: + inner = inner.replace("''", "'") return inner return value +_BLOCK_SCALAR_HEADER_RE = re.compile(r"^([>|])([+-]?)([1-9]?)$") + + +def _parse_yaml_lines(lines: list[str]) -> dict[str, Any]: + """Parse a flat YAML mapping. + + Supports inline scalars (quoted or bare) and block scalars + (>, |, >-, |-, >+, |+, with optional indent indicator). + """ + fm: dict[str, Any] = {} + i = 0 + while i < len(lines): + line = lines[i] + if ":" not in line: + i += 1 + continue + + key, value = line.split(":", 1) + key = key.strip() + if not key: + i += 1 + continue + + parent_indent = len(line) - len(line.lstrip(" ")) + value = value.strip() + block_header = _BLOCK_SCALAR_HEADER_RE.match(value) + if not block_header: + fm[key] = _yaml_unquote(value) + i += 1 + continue + + style, chomping, indent_indicator = block_header.groups() + explicit_indent = int(indent_indicator) if indent_indicator else None + block_indent: int | None = explicit_indent + block_lines: list[str] = [] + i += 1 + + while i < len(lines): + continuation = lines[i] + if continuation.strip(): + indent = len(continuation) - len(continuation.lstrip(" ")) + if block_indent is None: + if indent <= parent_indent: + break + block_indent = indent + if indent < block_indent: + break + block_lines.append(continuation[block_indent:]) + else: + block_lines.append("") + i += 1 + + parsed = _render_block_scalar(block_lines, style, chomping) + fm[key] = parsed + + return fm + + +def _render_block_scalar(lines: list[str], style: str, chomping: str) -> str: + """Render collected block scalar lines according to the supported subset.""" + if style == "|": + value = "\n".join(lines) + else: + paragraphs: list[str] = [] + current: list[str] = [] + for line in lines: + if line == "": + if current: + paragraphs.append(" ".join(current)) + current = [] + else: + current.append(line) + if current: + paragraphs.append(" ".join(current)) + value = "\n".join(paragraphs) + + trailing_empty_count = 0 + for line in reversed(lines): + if line == "": + trailing_empty_count += 1 + else: + break + + value = value.rstrip("\n") + if chomping == "-": + return value + if chomping == "+": + return value + ("\n" * (trailing_empty_count + 1)) + return value + "\n" + + def parse_frontmatter(content: str) -> Dict[str, Any]: """Parse YAML frontmatter into a flat dict. - Simple line-by-line parser (no PyYAML dependency). - Handles both quoted and unquoted values. + Dependency-free parser for flat mappings. + Handles quoted/unquoted values and YAML block scalars. Returns ``{}`` if no valid frontmatter is found. """ if not content.startswith("---"): @@ -88,14 +193,7 @@ def parse_frontmatter(content: str) -> Dict[str, Any]: match = _FRONTMATTER_RE.match(content) if not match: return {} - fm: Dict[str, Any] = {} - for line in match.group(1).split("\n"): - if ":" in line: - key, value = line.split(":", 1) - key = key.strip() - if key: - fm[key] = _yaml_unquote(value.strip()) - return fm + return _parse_yaml_lines(match.group(1).split("\n")) def get_frontmatter_field(content: str, field_name: str) -> Optional[str]: @@ -108,12 +206,9 @@ def get_frontmatter_field(content: str, field_name: str) -> Optional[str]: match = _FRONTMATTER_RE.match(content) if not match: return None - for line in match.group(1).split("\n"): - if ":" in line: - key, value = line.split(":", 1) - if key.strip() == field_name: - return _yaml_unquote(value.strip()) - return None + fm = _parse_yaml_lines(match.group(1).split("\n")) + value = fm.get(field_name) + return value if isinstance(value, str) else None def set_frontmatter_field(content: str, field_name: str, value: str) -> str: @@ -306,4 +401,3 @@ def truncate(text: str, max_chars: int) -> str: if len(text) <= max_chars: return text return text[:max_chars] + f"\n\n... [truncated at {max_chars} chars]" - diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/skill_engine/__init__.py b/tests/skill_engine/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/skill_engine/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/skill_engine/test_skill_utils.py b/tests/skill_engine/test_skill_utils.py new file mode 100644 index 0000000..a739b7a --- /dev/null +++ b/tests/skill_engine/test_skill_utils.py @@ -0,0 +1,98 @@ +import sys +import types + +sys.modules.setdefault("colorama", types.SimpleNamespace(init=lambda **_: None)) + +from openspace.skill_engine.skill_utils import ( + get_frontmatter_field, + normalize_frontmatter, + parse_frontmatter, +) + + +def _content(frontmatter: str) -> str: + return f"---\n{frontmatter}\n---\nBody\n" + + +def test_inline_unquoted(): + assert parse_frontmatter(_content("description: hello"))["description"] == "hello" + + +def test_inline_double_quoted(): + assert ( + parse_frontmatter(_content('description: "hello: world"'))["description"] + == "hello: world" + ) + + +def test_inline_single_quoted(): + assert parse_frontmatter(_content("description: 'don''t'"))["description"] == "don't" + + +def test_block_folded(): + fm = parse_frontmatter(_content("description: >\n line1\n line2")) + assert fm["description"] == "line1 line2\n" + + +def test_block_literal(): + fm = parse_frontmatter(_content("description: |\n line1\n line2")) + assert fm["description"] == "line1\nline2\n" + + +def test_block_folded_strip(): + fm = parse_frontmatter(_content("description: >-\n line1\n line2")) + assert fm["description"] == "line1 line2" + + +def test_block_literal_keep_all(): + fm = parse_frontmatter(_content("description: |+\n line1\n line2\n\n")) + assert fm["description"] == "line1\nline2\n\n\n" + + +def test_block_with_blank_line_in_folded(): + fm = parse_frontmatter(_content("description: >\n line1\n\n line2")) + assert fm["description"] == "line1\nline2\n" + + +def test_block_followed_by_next_key(): + fm = parse_frontmatter(_content("description: >-\n line1\n line2\nname: next")) + assert fm["description"] == "line1 line2" + assert fm["name"] == "next" + + +def test_get_frontmatter_field_block_scalar(): + content = _content("description: >-\n line1\n line2") + assert get_frontmatter_field(content, "description") == "line1 line2" + + +def test_normalize_roundtrip_block_scalar(): + content = _content("description: >-\n foo\n bar") + normalized = normalize_frontmatter(content) + assert parse_frontmatter(normalized)["description"] == "foo bar" + + +def test_normalize_preserves_existing_inline(): + content = _content("description: hello\nname: skill") + assert normalize_frontmatter(content) == content + + +FIXTURE = """--- +name: by-codex-delegation +description: > + Decide whether to delegate implementation to Codex vs implement inline, + write the SPARC brief, and hand off correctly. +triggers: + - 'delegate to codex' +--- + +# By Codex Delegation +Body content. +""" + + +def test_fixture_realworld(): + fm = parse_frontmatter(FIXTURE) + assert fm["name"] == "by-codex-delegation" + assert "Decide whether to delegate" in fm["description"] + assert "SPARC brief" in fm["description"] + assert fm["description"] != ">"