Skip to content

Commit 6236c8c

Browse files
tbitcsoz-agent
andcommitted
fix(phase,sync): merge REQ-358+REQ-359 \u2014 H2 heading support and YAML-mode MD fallback
Co-Authored-By: Oz <oz-agent@warp.dev>
2 parents 3259963 + 1125c24 commit 6236c8c

3 files changed

Lines changed: 119 additions & 2 deletions

File tree

src/specsmith/phase.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ def _check(root: Path) -> bool:
7777
if p.exists():
7878
try:
7979
text = p.read_text(encoding="utf-8", errors="ignore")
80-
# Support both ### REQ-NNN (old) and ## N. Title / - **ID:** REQ-NNN (new)
81-
count = len(re.findall(r"^###\s+REQ-", text, re.MULTILINE))
80+
# Support H2 (## REQ-NNN), H3 (### REQ-NNN), and domain-namespaced (## REQ-BE-001)
81+
count = len(re.findall(r"^#{2,3}\s+REQ-", text, re.MULTILINE))
8282
if count == 0:
8383
count = len(re.findall(r"- \*\*ID:\*\* REQ-", text))
8484
if count == 0:

src/specsmith/sync.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,17 @@ def run_sync(root: Path, *, dry_run: bool = False) -> SyncResult:
240240
new_reqs = load_yaml_requirements(root)
241241
new_tests = load_yaml_tests(root)
242242

243+
# REQ-358: If YAML mode has no YAML files but REQUIREMENTS.md has content,
244+
# fall back to Markdown parsing rather than silently producing an empty state.
245+
if not new_reqs and reqs_md_path.exists():
246+
_md_text = reqs_md_path.read_text(encoding="utf-8")
247+
import re as _re
248+
if len(_re.findall(r"REQ-[A-Z0-9-]+", _md_text)) >= 5:
249+
new_reqs = parse_requirements_md(_md_text)
250+
251+
if not new_tests and tests_md_path.exists():
252+
new_tests = parse_tests_md(tests_md_path.read_text(encoding="utf-8"))
253+
243254
# Normalise to the same schema that the Markdown path produces
244255
# Build req_id → [test_ids] map from the loaded tests so requirements.json
245256
# includes test_ids for audit coverage checks (#147).

tests/test_req_358_359.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3+
"""Tests for REQ-358 (_req_count H2 support) and REQ-359 (sync YAML-mode MD fallback)."""
4+
5+
from __future__ import annotations
6+
7+
import json
8+
from pathlib import Path
9+
10+
11+
# ── REQ-359 / TEST-359: _req_count H2 heading support ─────────────────────────
12+
13+
14+
class TestReqCountH2:
15+
"""_req_count should match ## REQ-XX-NNN (H2) headings, not just ### (H3)."""
16+
17+
def test_req_count_h2_headings(self, tmp_path: Path) -> None:
18+
from specsmith.phase import _req_count
19+
20+
docs = tmp_path / "docs"
21+
docs.mkdir()
22+
lines = [f"## REQ-BE-{i:03d}: Requirement {i}" for i in range(1, 6)]
23+
(docs / "REQUIREMENTS.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
24+
25+
assert _req_count(5)(tmp_path) is True
26+
assert _req_count(6)(tmp_path) is False # strict boundary
27+
28+
def test_req_count_h3_still_works(self, tmp_path: Path) -> None:
29+
from specsmith.phase import _req_count
30+
31+
docs = tmp_path / "docs"
32+
docs.mkdir()
33+
lines = [f"### REQ-{i:03d}: Requirement {i}" for i in range(1, 4)]
34+
(docs / "REQUIREMENTS.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
35+
36+
assert _req_count(3)(tmp_path) is True
37+
assert _req_count(4)(tmp_path) is False
38+
39+
def test_req_count_mixed_h2_h3(self, tmp_path: Path) -> None:
40+
from specsmith.phase import _req_count
41+
42+
docs = tmp_path / "docs"
43+
docs.mkdir()
44+
content = (
45+
"## REQ-BE-001: First\n"
46+
"### REQ-BE-002: Second\n"
47+
"## REQ-BE-003: Third\n"
48+
)
49+
(docs / "REQUIREMENTS.md").write_text(content, encoding="utf-8")
50+
51+
assert _req_count(3)(tmp_path) is True
52+
assert _req_count(4)(tmp_path) is False
53+
54+
55+
# ── REQ-358 / TEST-358: sync YAML-mode Markdown fallback ──────────────────────
56+
57+
58+
class TestSyncYamlModeMarkdownFallback:
59+
"""run_sync in YAML mode should fall back to REQUIREMENTS.md parsing
60+
when no YAML requirement files exist but REQUIREMENTS.md has content."""
61+
62+
def test_sync_yaml_mode_markdown_fallback(self, tmp_path: Path) -> None:
63+
from specsmith.sync import run_sync
64+
65+
# Set up YAML mode
66+
state_dir = tmp_path / ".specsmith"
67+
state_dir.mkdir()
68+
(state_dir / "governance-mode").write_text("yaml", encoding="utf-8")
69+
70+
# Create REQUIREMENTS.md with H2 headings (no YAML files)
71+
docs = tmp_path / "docs"
72+
docs.mkdir()
73+
lines = [f"## REQ-BE-{i:03d}: Backend requirement {i}" for i in range(1, 7)]
74+
(docs / "REQUIREMENTS.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
75+
76+
# No docs/requirements/ directory — forces fallback
77+
78+
result = run_sync(tmp_path)
79+
80+
assert result.reqs_after >= 6
81+
reqs_json = state_dir / "requirements.json"
82+
assert reqs_json.exists()
83+
data = json.loads(reqs_json.read_text(encoding="utf-8"))
84+
assert len(data) == 6
85+
86+
def test_sync_yaml_mode_no_fallback_when_yaml_exists(self, tmp_path: Path) -> None:
87+
"""When YAML files exist, no fallback should occur."""
88+
from specsmith.sync import run_sync
89+
90+
state_dir = tmp_path / ".specsmith"
91+
state_dir.mkdir()
92+
(state_dir / "governance-mode").write_text("yaml", encoding="utf-8")
93+
94+
docs = tmp_path / "docs"
95+
docs.mkdir()
96+
req_dir = docs / "requirements"
97+
req_dir.mkdir()
98+
(req_dir / "core.yml").write_text(
99+
"- id: REQ-001\n title: First\n status: defined\n",
100+
encoding="utf-8",
101+
)
102+
103+
result = run_sync(tmp_path)
104+
105+
# Should use YAML source, not fallback
106+
assert result.reqs_after == 1

0 commit comments

Comments
 (0)