diff --git a/.gitignore b/.gitignore index 27e8dfb0..000826d6 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_detect_patch_type_prose.py !tests/skill_engine/decision/ tests/skill_engine/decision/* !tests/skill_engine/decision/test_analysis_adapter.py diff --git a/openspace/skill_engine/patch.py b/openspace/skill_engine/patch.py index c0668893..dfa5e703 100644 --- a/openspace/skill_engine/patch.py +++ b/openspace/skill_engine/patch.py @@ -482,11 +482,14 @@ def detect_patch_type(content: str) -> PatchType: if "*** Begin Files" in content: return PatchType.FULL - # Detect bare *** File: markers (no *** Begin Files envelope). - # Must appear at line start; require at least one to look structural. - file_header_hits = _FILE_HEADER_RE.findall(content) - if file_header_hits: + # ≥2 File: markers, or one at content start with a body (docstring rule 3). + file_headers = list(_FILE_HEADER_RE.finditer(content)) + if len(file_headers) >= 2: return PatchType.FULL + if len(file_headers) == 1: + match = file_headers[0] + if not content[: match.start()].strip() and content[match.end() :].strip(): + return PatchType.FULL if "<<<<<<< SEARCH" in content: return PatchType.DIFF @@ -527,6 +530,10 @@ def parse_multi_file_full(content: str) -> Dict[str, str]: # No multi-file markers — treat entire content as SKILL.md return {SKILL_FILENAME: content} + # Single mid-document *** File: is prose/example text, not multi-file. + if len(headers) == 1 and stripped[: headers[0].start()].strip(): + return {SKILL_FILENAME: content} + files: Dict[str, str] = {} for i, match in enumerate(headers): file_path = match.group(1).strip() diff --git a/tests/skill_engine/test_detect_patch_type_prose.py b/tests/skill_engine/test_detect_patch_type_prose.py new file mode 100644 index 00000000..98c04fe8 --- /dev/null +++ b/tests/skill_engine/test_detect_patch_type_prose.py @@ -0,0 +1,38 @@ +"""Regression: mid-document *** File: prose must not drop SKILL.md.""" + +from __future__ import annotations + +from pathlib import Path + +from openspace.skill_engine.patch import ( + PatchType, + create_skill, + detect_patch_type, + parse_multi_file_full, +) + + +def test_detect_patch_type_ignores_single_mid_document_file_marker(): + content = "# My Skill\n\n*** File: helper.sh\n#!/bin/bash\necho hi\n" + assert detect_patch_type(content) == PatchType.FULL + parsed = parse_multi_file_full(content) + assert list(parsed.keys()) == ["SKILL.md"] + assert "*** File: helper.sh" in parsed["SKILL.md"] + + +def test_create_skill_keeps_skill_md_when_file_marker_is_prose(tmp_path: Path): + content = "# My Skill\n\n*** File: helper.sh\n#!/bin/bash\necho hi\n" + result = create_skill(tmp_path / "s", content) + assert result.ok + skill_md = tmp_path / "s" / "SKILL.md" + assert skill_md.is_file() + assert "*** File: helper.sh" in skill_md.read_text() + assert not (tmp_path / "s" / "helper.sh").exists() + + +def test_detect_patch_type_accepts_structural_multi_file_at_start(): + content = "*** File: SKILL.md\n# Skill\n\n*** File: helper.sh\n#!/bin/bash\necho hi\n" + assert detect_patch_type(content) == PatchType.FULL + parsed = parse_multi_file_full(content) + assert "SKILL.md" in parsed + assert "helper.sh" in parsed