Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions src/specsmith/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@

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

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable '_FLEX_TEST' is not used.

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

# Heading detectors for REQUIREMENTS.md (two styles supported):
# Style A: ## REQ-001 or ## REQ-CLI-001
Expand Down Expand Up @@ -111,16 +114,45 @@


def trace_reqs(root: Path) -> list[dict[str, object]]:
"""Map each REQ to its covering TESTs."""
"""Map each REQ to its covering TESTs.

In YAML-first mode, reads .specsmith/testcases.json directly — this avoids
regex-based ID parsing entirely and correctly handles letter-suffix IDs
(e.g. TEST-NN-002a, TEST-NN-002b) that were previously misidentified (#183).
Falls back to TESTS.md regex parsing in legacy Markdown mode.
"""
import json as _json

req_path = root / "docs" / "REQUIREMENTS.md"
test_path = root / "docs" / "TESTS.md"
testcases_json = root / ".specsmith" / "testcases.json"

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

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

# YAML mode: prefer machine-readable testcases.json — exact IDs, no regex.
if testcases_json.is_file():
try:
records = _json.loads(testcases_json.read_text(encoding="utf-8"))
for record in records:
if (
isinstance(record, dict)
and isinstance(record.get("requirement_id"), str)
and isinstance(record.get("id"), str)
):
rid = record["requirement_id"]
if rid in covered_by:
covered_by[rid].append(record["id"])
return [
{"req": r, "tests": tests, "covered": len(tests) > 0}
for r, tests in covered_by.items()
]
except (OSError, ValueError):
pass # Fall through to TESTS.md regex parsing

if test_path.exists():
test_text = test_path.read_text(encoding="utf-8")
current_test = ""
Expand Down
3 changes: 2 additions & 1 deletion src/specsmith/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
_ID_FIELD = re.compile(r"^-\s+\*\*ID:\*\*\s+(" + _FLEX_REQ_ID + r")")
_FIELD_LINE = re.compile(r"^-\s+\*\*(.+?):\*\*\s+(.+)")

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

Expand Down
112 changes: 112 additions & 0 deletions tests/test_auditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,115 @@ def test_req_test_coverage(self, governed_project: Path) -> None:
assert len(coverage) == 1
assert not coverage[0].passed
assert "REQ-CLI-002" in coverage[0].message


# ---------------------------------------------------------------------------
# Regression: issue #183 — letter-suffix TEST IDs in req trace
# ---------------------------------------------------------------------------


class TestReqTraceLetterSuffixRegression:
"""Regression tests for #183 — req trace misidentifies TEST-NN-002a/b.

Root cause: _TEST_ID_PATTERN used \\d+\\b which fails to match when a
letter immediately follows digits (\\b requires a non-word char boundary,
but letters are word chars). This caused current_test to remain pointing
at the last successfully-parsed ID (TEST-NN-020), so both TEST-NN-002a and
TEST-NN-002b coverage lines were erroneously attributed to TEST-NN-020.
"""

def test_letter_suffix_ids_captured_from_tests_md(self, tmp_path: Path) -> None:
"""trace_reqs must return TEST-NN-002a / TEST-NN-002b, not TEST-NN-020 twice."""
from specsmith.requirements import trace_reqs

docs = tmp_path / "docs"
docs.mkdir()
(docs / "REQUIREMENTS.md").write_text(
"# Requirements\n\n## REQ-NN-001\n- **Status**: defined\n\n"
"## REQ-NN-002\n- **Status**: defined\n",
encoding="utf-8",
)
# Simulate the buggy scenario: TEST-NN-020 appears BEFORE TEST-NN-002a/b
# so it would stick as current_test if letter suffixes aren't parsed.
(docs / "TESTS.md").write_text(
"# Tests\n\n"
"## TEST-NN-020\n- **Requirement ID**: REQ-NN-001\n\n"
"## TEST-NN-002a\n- **Requirement ID**: REQ-NN-002\n\n"
"## TEST-NN-002b\n- **Requirement ID**: REQ-NN-002\n",
encoding="utf-8",
)

traces = trace_reqs(tmp_path)
by_req = {t["req"]: t["tests"] for t in traces}

# REQ-NN-001 should map to TEST-NN-020 only
assert by_req.get("REQ-NN-001") == ["TEST-NN-020"]
# REQ-NN-002 must map to TEST-NN-002a and TEST-NN-002b — NOT TEST-NN-020 twice
assert "TEST-NN-002a" in by_req.get("REQ-NN-002", [])
assert "TEST-NN-002b" in by_req.get("REQ-NN-002", [])
assert "TEST-NN-020" not in by_req.get("REQ-NN-002", [])

def test_no_duplicate_ids_in_trace(self, tmp_path: Path) -> None:
"""REQ-NN-002 must not have duplicate entries in its test list."""
from specsmith.requirements import trace_reqs

docs = tmp_path / "docs"
docs.mkdir()
(docs / "REQUIREMENTS.md").write_text(
"# Requirements\n\n## REQ-NN-002\n- **Status**: defined\n",
encoding="utf-8",
)
(docs / "TESTS.md").write_text(
"# Tests\n\n"
"## TEST-NN-002a\n- **Requirement ID**: REQ-NN-002\n\n"
"## TEST-NN-002b\n- **Requirement ID**: REQ-NN-002\n",
encoding="utf-8",
)

traces = trace_reqs(tmp_path)
tests = traces[0]["tests"]
assert len(tests) == len(set(tests)), f"Duplicate test IDs: {tests}"

def test_yaml_mode_uses_testcases_json(self, tmp_path: Path) -> None:
"""In YAML mode, trace_reqs reads testcases.json directly (no regex)."""
import json

from specsmith.requirements import trace_reqs

docs = tmp_path / "docs"
docs.mkdir()
(docs / "REQUIREMENTS.md").write_text(
"# Requirements\n\n## REQ-NN-002\n- **Status**: defined\n",
encoding="utf-8",
)
state = tmp_path / ".specsmith"
state.mkdir()
(state / "testcases.json").write_text(
json.dumps(
[
{"id": "TEST-NN-002a", "requirement_id": "REQ-NN-002"},
{"id": "TEST-NN-002b", "requirement_id": "REQ-NN-002"},
]
),
encoding="utf-8",
)

traces = trace_reqs(tmp_path)
by_req = {t["req"]: t["tests"] for t in traces}
assert sorted(by_req["REQ-NN-002"]) == ["TEST-NN-002a", "TEST-NN-002b"]

def test_sync_parse_tests_md_preserves_letter_suffix(self, tmp_path: Path) -> None:
"""sync.parse_tests_md must not truncate TEST-NN-002a to TEST-NN-002."""
from specsmith.sync import parse_tests_md

text = (
"## TEST-NN-002a\n"
"- **Requirement ID**: REQ-NN-002\n\n"
"## TEST-NN-002b\n"
"- **Requirement ID**: REQ-NN-002\n"
)
records = parse_tests_md(text)
ids = [r["id"] for r in records]
assert "TEST-NN-002a" in ids
assert "TEST-NN-002b" in ids
assert "TEST-NN-002" not in ids # must not be truncated
Loading