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:
Sergey 2026-08-03 15:08:50 +03:00 committed by GitHub
parent 7d1ef60f6f
commit cc2c896290
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 10 deletions

View file

@ -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():

View file

@ -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) ───────────────────────────────────────────────────