From cc2c89629055cff6f77ccdd8c6840b499d7fc62e Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:08:50 +0300 Subject: [PATCH] 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 --- .opencode/scripts/spec-status.py | 28 +++++++++++++----- tests/test_spec_status.py | 50 ++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/.opencode/scripts/spec-status.py b/.opencode/scripts/spec-status.py index 879a75a..cbfe03c 100644 --- a/.opencode/scripts/spec-status.py +++ b/.opencode/scripts/spec-status.py @@ -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(): diff --git a/tests/test_spec_status.py b/tests/test_spec_status.py index ba98b9c..dfb5712 100644 --- a/tests/test_spec_status.py +++ b/tests/test_spec_status.py @@ -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) ───────────────────────────────────────────────────