fix(spec): spec-status parser quotes crlf bom (#232)
* fix(spec): strip quotes crlf bom in spec-status parser * test(spec): cover quotes crlf bom trailing newline cases * fix(ci): format test_spec_status.py write_bytes call --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
7d1ef60f6f
commit
cc2c896290
2 changed files with 68 additions and 10 deletions
|
|
@ -90,7 +90,7 @@ PHASE_NAMES = [
|
|||
"EXECUTE",
|
||||
]
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
|
||||
FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
|
||||
KV_RE = re.compile(r"^(\w+):\s*(.*?)$", re.MULTILINE)
|
||||
|
||||
|
||||
|
|
@ -152,22 +152,34 @@ def get_repo_full_name() -> str:
|
|||
|
||||
|
||||
def parse_frontmatter(content: str) -> dict[str, str]:
|
||||
"""Parse simple key: value frontmatter (no nested structures)."""
|
||||
"""Parse simple key: value frontmatter (no nested structures).
|
||||
|
||||
Strips one balanced pair of surrounding single/double quotes from each
|
||||
value (e.g. ``type: 'fullstack'`` -> ``fullstack``). Unbalanced quotes
|
||||
are preserved verbatim.
|
||||
"""
|
||||
match = FRONTMATTER_RE.search(content)
|
||||
if not match:
|
||||
return {}
|
||||
fm_text = match.group(1)
|
||||
return dict(KV_RE.findall(fm_text))
|
||||
parsed: dict[str, str] = {}
|
||||
for key, raw in KV_RE.findall(fm_text):
|
||||
value = raw.strip()
|
||||
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
|
||||
value = value[1:-1]
|
||||
parsed[key] = value
|
||||
return parsed
|
||||
|
||||
|
||||
def read_meta() -> tuple[str, dict[str, str]]:
|
||||
"""Read docs/spec/meta.md content + parsed frontmatter.
|
||||
|
||||
Returns ``("", {})`` if meta.md is missing.
|
||||
Returns ``("", {})`` if meta.md is missing. Uses ``utf-8-sig`` to
|
||||
transparently strip a leading BOM if present.
|
||||
"""
|
||||
if not META_FILE.exists():
|
||||
return "", {}
|
||||
content = META_FILE.read_text()
|
||||
content = META_FILE.read_text(encoding="utf-8-sig")
|
||||
return content, parse_frontmatter(content)
|
||||
|
||||
|
||||
|
|
@ -260,7 +272,7 @@ def check_modules() -> PhaseResult:
|
|||
def check_db_schema() -> PhaseResult:
|
||||
"""Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled."""
|
||||
_content, fm = read_meta()
|
||||
if fm.get("no_db", "").strip().lower() in {"true", '"true"'}:
|
||||
if fm.get("no_db", "").strip().lower() == "true":
|
||||
return PhaseResult(PhaseStatus.DONE, "no_db: true (DB не нужна)")
|
||||
db_file = SPEC_DIR / PHASE_FILES[4]
|
||||
if not file_filled(db_file):
|
||||
|
|
@ -304,7 +316,7 @@ def check_confirm() -> PhaseResult:
|
|||
"""Phase 7: CONFIRM — confirmed: true in meta.md frontmatter."""
|
||||
_content, fm = read_meta()
|
||||
val = fm.get("confirmed", "").strip().lower()
|
||||
if val not in {"true", '"true"'}:
|
||||
if val != "true":
|
||||
return PhaseResult(PhaseStatus.NOT_DONE, "confirmed: true отсутствует в frontmatter")
|
||||
return PhaseResult(PhaseStatus.DONE, "spec подтверждён юзером")
|
||||
|
||||
|
|
@ -336,7 +348,7 @@ def check_execute() -> PhaseResult:
|
|||
"""Phase 8: EXECUTE — executed: true + issues created (gh view --repo)."""
|
||||
_content, fm = read_meta()
|
||||
val = fm.get("executed", "").strip().lower()
|
||||
if val not in {"true", '"true"'}:
|
||||
if val != "true":
|
||||
return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter")
|
||||
roadmap_file = SPEC_DIR / PHASE_FILES[6]
|
||||
if not roadmap_file.exists():
|
||||
|
|
|
|||
|
|
@ -102,8 +102,33 @@ def test_parse_frontmatter_basic():
|
|||
def test_parse_frontmatter_quoted_values():
|
||||
content = '---\nproject: "foo bar"\ntype: "backend"\n---\n'
|
||||
fm = ss.parse_frontmatter(content)
|
||||
assert fm["project"] == '"foo bar"'
|
||||
assert fm["type"] == '"backend"'
|
||||
assert fm["project"] == "foo bar"
|
||||
assert fm["type"] == "backend"
|
||||
|
||||
|
||||
def test_parse_frontmatter_single_quoted_values():
|
||||
content = "---\nproject: 'foo bar'\ntype: 'backend'\n---\n"
|
||||
fm = ss.parse_frontmatter(content)
|
||||
assert fm["project"] == "foo bar"
|
||||
assert fm["type"] == "backend"
|
||||
|
||||
|
||||
def test_parse_frontmatter_crlf():
|
||||
"""CRLF line endings are supported (Windows-style meta.md)."""
|
||||
content = "---\r\nproject: foo\r\ntype: backend\r\n---\r\n"
|
||||
assert ss.parse_frontmatter(content) == {"project": "foo", "type": "backend"}
|
||||
|
||||
|
||||
def test_parse_frontmatter_missing_trailing_newline():
|
||||
"""Frontmatter without trailing newline (Write tool) is still parsed."""
|
||||
content = "---\nproject: foo\ntype: backend\n---"
|
||||
assert ss.parse_frontmatter(content) == {"project": "foo", "type": "backend"}
|
||||
|
||||
|
||||
def test_parse_frontmatter_unbalanced_quote_preserved():
|
||||
"""Unbalanced quotes are kept verbatim (no strip)."""
|
||||
content = '---\nproject: "foo\n---\n'
|
||||
assert ss.parse_frontmatter(content) == {"project": '"foo'}
|
||||
|
||||
|
||||
def test_parse_frontmatter_multiline_body():
|
||||
|
|
@ -324,6 +349,27 @@ def test_has_bullet_items_star(tmp_path):
|
|||
assert ss.has_bullet_items(f)
|
||||
|
||||
|
||||
# ── read_meta (BOM + combined) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_read_meta_strips_bom(monkeypatch, tmp_path):
|
||||
"""BOM (utf-8-sig) is stripped so frontmatter parsing still works."""
|
||||
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
|
||||
meta = spec_dir / "meta.md"
|
||||
meta.write_bytes(b"\xef\xbb\xbf---\nproject: foo\ntype: backend\n---\n")
|
||||
_content, fm = ss.read_meta()
|
||||
assert fm == {"project": "foo", "type": "backend"}
|
||||
|
||||
|
||||
def test_read_meta_combined_bom_crlf_quotes(monkeypatch, tmp_path):
|
||||
"""BOM + CRLF + quoted values parsed together (regression combo)."""
|
||||
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
|
||||
meta = spec_dir / "meta.md"
|
||||
meta.write_bytes(b"\xef\xbb\xbf---\r\nproject: \"foo bar\"\r\ntype: 'backend'\r\n---\r\n")
|
||||
_content, fm = ss.read_meta()
|
||||
assert fm == {"project": "foo bar", "type": "backend"}
|
||||
|
||||
|
||||
# ── check_detect (Phase 0) ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue