Skip to content

Commit 2469acc

Browse files
tbitcsoz-agent
andauthored
fix(req-trace): handle letter-suffix TEST IDs (TEST-NN-002a/b) — closes #183 (#185)
* fix(req-trace): handle letter-suffix TEST IDs (TEST-NN-002a/b) — closes #183 Root cause: _TEST_ID_PATTERN used \\d+\\b which cannot match when a letter immediately follows digits (\\b is not a word boundary between \\d and [a-z]). This caused current_test to remain at the last clean ID (TEST-NN-020), so both TEST-NN-002a and TEST-NN-002b coverage lines were attributed to it. Changes: - requirements.py: extend _FLEX_TEST and _TEST_ID_PATTERN to \\d+[a-z]* - requirements.py: trace_reqs() prefers .specsmith/testcases.json in YAML mode — exact IDs, no regex parsing needed (mirrors preflight behaviour) - sync.py: extend _FLEX_TEST_ID to \\d+[a-z]* so parse_tests_md() does not silently drop letter-suffix headings (## TEST-NN-002a) from testcases.json 4 regression tests added to TestReqTraceLetterSuffixRegression. Co-Authored-By: Oz <oz-agent@warp.dev> * style: ruff format test_auditor.py Co-Authored-By: Oz <oz-agent@warp.dev> --------- Co-authored-by: Oz <oz-agent@warp.dev>
1 parent ed2fd4a commit 2469acc

3 files changed

Lines changed: 149 additions & 4 deletions

File tree

src/specsmith/requirements.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@
99

1010
# Flexible patterns that handle both two-part (REQ-001) and three-part
1111
# (REQ-CLI-001, REG-012) identifiers used across projects.
12+
# Letter suffixes (e.g. TEST-NN-002a, TEST-NN-002b) are supported via [a-z]* —
13+
# without this, the \b word boundary after \d+ would not match when a letter
14+
# follows digits, causing the ID to be silently skipped (#183).
1215
_FLEX_REQ = r"REQ-(?:[A-Z][A-Z0-9_]*-)?\d+"
13-
_FLEX_TEST = r"TEST-(?:[A-Z][A-Z0-9_]*-)?\d+"
16+
_FLEX_TEST = r"TEST-(?:[A-Z][A-Z0-9_]*-)?\d+[a-z]*"
1417

1518
_REQ_PATTERN = re.compile(r"\b(" + _FLEX_REQ + r")\b")
1619
_TEST_COVERS_PATTERN = re.compile(
1720
r"(?:Covers|\*\*Requirement(?:\s+ID)?:?\*\*|Requirement(?:\s+ID)?):?\s*"
1821
r"(" + _FLEX_REQ + r"(?:\s*,\s*" + _FLEX_REQ + r")*)"
1922
)
20-
_TEST_ID_PATTERN = re.compile(r"\b(" + _FLEX_TEST + r")\b")
23+
_TEST_ID_PATTERN = re.compile(r"\b(TEST-(?:[A-Z][A-Z0-9_]*-)?\d+[a-z]*)\b")
2124

2225
# Heading detectors for REQUIREMENTS.md (two styles supported):
2326
# Style A: ## REQ-001 or ## REQ-CLI-001
@@ -111,16 +114,45 @@ def add_req(
111114

112115

113116
def trace_reqs(root: Path) -> list[dict[str, object]]:
114-
"""Map each REQ to its covering TESTs."""
117+
"""Map each REQ to its covering TESTs.
118+
119+
In YAML-first mode, reads .specsmith/testcases.json directly — this avoids
120+
regex-based ID parsing entirely and correctly handles letter-suffix IDs
121+
(e.g. TEST-NN-002a, TEST-NN-002b) that were previously misidentified (#183).
122+
Falls back to TESTS.md regex parsing in legacy Markdown mode.
123+
"""
124+
import json as _json
125+
115126
req_path = root / "docs" / "REQUIREMENTS.md"
116127
test_path = root / "docs" / "TESTS.md"
128+
testcases_json = root / ".specsmith" / "testcases.json"
117129

118130
req_ids: list[str] = []
119131
if req_path.exists():
120132
req_ids = sorted(set(_REQ_PATTERN.findall(req_path.read_text(encoding="utf-8"))))
121133

122134
covered_by: dict[str, list[str]] = {r: [] for r in req_ids}
123135

136+
# YAML mode: prefer machine-readable testcases.json — exact IDs, no regex.
137+
if testcases_json.is_file():
138+
try:
139+
records = _json.loads(testcases_json.read_text(encoding="utf-8"))
140+
for record in records:
141+
if (
142+
isinstance(record, dict)
143+
and isinstance(record.get("requirement_id"), str)
144+
and isinstance(record.get("id"), str)
145+
):
146+
rid = record["requirement_id"]
147+
if rid in covered_by:
148+
covered_by[rid].append(record["id"])
149+
return [
150+
{"req": r, "tests": tests, "covered": len(tests) > 0}
151+
for r, tests in covered_by.items()
152+
]
153+
except (OSError, ValueError):
154+
pass # Fall through to TESTS.md regex parsing
155+
124156
if test_path.exists():
125157
test_text = test_path.read_text(encoding="utf-8")
126158
current_test = ""

src/specsmith/sync.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
_ID_FIELD = re.compile(r"^-\s+\*\*ID:\*\*\s+(" + _FLEX_REQ_ID + r")")
4242
_FIELD_LINE = re.compile(r"^-\s+\*\*(.+?):\*\*\s+(.+)")
4343

44-
_FLEX_TEST_ID = r"TEST-(?:[A-Z][A-Z0-9_]*-)?\d+"
44+
# Letter suffixes (e.g. TEST-NN-002a) are supported via [a-z]* — fixes #183.
45+
_FLEX_TEST_ID = r"TEST-(?:[A-Z][A-Z0-9_]*-)?\d+[a-z]*"
4546
_TEST_NUMBERED_HEADING = re.compile(r"^#{1,3}\s+(?:TEST-[A-Z0-9_-]+\s+)?(.+?)\s*$")
4647
_TEST_ID_FIELD = re.compile(r"^-\s+\*\*ID:\*\*\s+(" + _FLEX_TEST_ID + r")")
4748

tests/test_auditor.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,115 @@ def test_req_test_coverage(self, governed_project: Path) -> None:
8787
assert len(coverage) == 1
8888
assert not coverage[0].passed
8989
assert "REQ-CLI-002" in coverage[0].message
90+
91+
92+
# ---------------------------------------------------------------------------
93+
# Regression: issue #183 — letter-suffix TEST IDs in req trace
94+
# ---------------------------------------------------------------------------
95+
96+
97+
class TestReqTraceLetterSuffixRegression:
98+
"""Regression tests for #183 — req trace misidentifies TEST-NN-002a/b.
99+
100+
Root cause: _TEST_ID_PATTERN used \\d+\\b which fails to match when a
101+
letter immediately follows digits (\\b requires a non-word char boundary,
102+
but letters are word chars). This caused current_test to remain pointing
103+
at the last successfully-parsed ID (TEST-NN-020), so both TEST-NN-002a and
104+
TEST-NN-002b coverage lines were erroneously attributed to TEST-NN-020.
105+
"""
106+
107+
def test_letter_suffix_ids_captured_from_tests_md(self, tmp_path: Path) -> None:
108+
"""trace_reqs must return TEST-NN-002a / TEST-NN-002b, not TEST-NN-020 twice."""
109+
from specsmith.requirements import trace_reqs
110+
111+
docs = tmp_path / "docs"
112+
docs.mkdir()
113+
(docs / "REQUIREMENTS.md").write_text(
114+
"# Requirements\n\n## REQ-NN-001\n- **Status**: defined\n\n"
115+
"## REQ-NN-002\n- **Status**: defined\n",
116+
encoding="utf-8",
117+
)
118+
# Simulate the buggy scenario: TEST-NN-020 appears BEFORE TEST-NN-002a/b
119+
# so it would stick as current_test if letter suffixes aren't parsed.
120+
(docs / "TESTS.md").write_text(
121+
"# Tests\n\n"
122+
"## TEST-NN-020\n- **Requirement ID**: REQ-NN-001\n\n"
123+
"## TEST-NN-002a\n- **Requirement ID**: REQ-NN-002\n\n"
124+
"## TEST-NN-002b\n- **Requirement ID**: REQ-NN-002\n",
125+
encoding="utf-8",
126+
)
127+
128+
traces = trace_reqs(tmp_path)
129+
by_req = {t["req"]: t["tests"] for t in traces}
130+
131+
# REQ-NN-001 should map to TEST-NN-020 only
132+
assert by_req.get("REQ-NN-001") == ["TEST-NN-020"]
133+
# REQ-NN-002 must map to TEST-NN-002a and TEST-NN-002b — NOT TEST-NN-020 twice
134+
assert "TEST-NN-002a" in by_req.get("REQ-NN-002", [])
135+
assert "TEST-NN-002b" in by_req.get("REQ-NN-002", [])
136+
assert "TEST-NN-020" not in by_req.get("REQ-NN-002", [])
137+
138+
def test_no_duplicate_ids_in_trace(self, tmp_path: Path) -> None:
139+
"""REQ-NN-002 must not have duplicate entries in its test list."""
140+
from specsmith.requirements import trace_reqs
141+
142+
docs = tmp_path / "docs"
143+
docs.mkdir()
144+
(docs / "REQUIREMENTS.md").write_text(
145+
"# Requirements\n\n## REQ-NN-002\n- **Status**: defined\n",
146+
encoding="utf-8",
147+
)
148+
(docs / "TESTS.md").write_text(
149+
"# Tests\n\n"
150+
"## TEST-NN-002a\n- **Requirement ID**: REQ-NN-002\n\n"
151+
"## TEST-NN-002b\n- **Requirement ID**: REQ-NN-002\n",
152+
encoding="utf-8",
153+
)
154+
155+
traces = trace_reqs(tmp_path)
156+
tests = traces[0]["tests"]
157+
assert len(tests) == len(set(tests)), f"Duplicate test IDs: {tests}"
158+
159+
def test_yaml_mode_uses_testcases_json(self, tmp_path: Path) -> None:
160+
"""In YAML mode, trace_reqs reads testcases.json directly (no regex)."""
161+
import json
162+
163+
from specsmith.requirements import trace_reqs
164+
165+
docs = tmp_path / "docs"
166+
docs.mkdir()
167+
(docs / "REQUIREMENTS.md").write_text(
168+
"# Requirements\n\n## REQ-NN-002\n- **Status**: defined\n",
169+
encoding="utf-8",
170+
)
171+
state = tmp_path / ".specsmith"
172+
state.mkdir()
173+
(state / "testcases.json").write_text(
174+
json.dumps(
175+
[
176+
{"id": "TEST-NN-002a", "requirement_id": "REQ-NN-002"},
177+
{"id": "TEST-NN-002b", "requirement_id": "REQ-NN-002"},
178+
]
179+
),
180+
encoding="utf-8",
181+
)
182+
183+
traces = trace_reqs(tmp_path)
184+
by_req = {t["req"]: t["tests"] for t in traces}
185+
assert sorted(by_req["REQ-NN-002"]) == ["TEST-NN-002a", "TEST-NN-002b"]
186+
187+
def test_sync_parse_tests_md_preserves_letter_suffix(self, tmp_path: Path) -> None:
188+
"""sync.parse_tests_md must not truncate TEST-NN-002a to TEST-NN-002."""
189+
from specsmith.sync import parse_tests_md
190+
191+
text = (
192+
"## TEST-NN-002a\n"
193+
"- **Requirement ID**: REQ-NN-002\n\n"
194+
"## TEST-NN-002b\n"
195+
"- **Requirement ID**: REQ-NN-002\n"
196+
)
197+
records = parse_tests_md(text)
198+
ids = [r["id"] for r in records]
199+
assert "TEST-NN-002a" in ids
200+
assert "TEST-NN-002b" in ids
201+
assert "TEST-NN-002" not in ids # must not be truncated

0 commit comments

Comments
 (0)