diff --git a/src/specsmith/requirements.py b/src/specsmith/requirements.py index 2d03f9f..54dcae2 100644 --- a/src/specsmith/requirements.py +++ b/src/specsmith/requirements.py @@ -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]*" _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 @@ -111,9 +114,18 @@ def add_req( 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(): @@ -121,6 +133,26 @@ def trace_reqs(root: Path) -> list[dict[str, object]]: 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 = "" diff --git a/src/specsmith/sync.py b/src/specsmith/sync.py index 4077d3d..7223036 100644 --- a/src/specsmith/sync.py +++ b/src/specsmith/sync.py @@ -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")") diff --git a/tests/test_auditor.py b/tests/test_auditor.py index ba5a8e3..d6ea30a 100644 --- a/tests/test_auditor.py +++ b/tests/test_auditor.py @@ -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