From 131dd91d57cc500e11a49871c9ddf36bd188a82b Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Wed, 20 May 2026 14:58:40 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(req-trace):=20handle=20letter-suffix=20?= =?UTF-8?q?TEST=20IDs=20(TEST-NN-002a/b)=20=E2=80=94=20closes=20#183?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/specsmith/requirements.py | 38 +++++++++++- src/specsmith/sync.py | 3 +- tests/test_auditor.py | 110 ++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 4 deletions(-) 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..a1c94b8 100644 --- a/tests/test_auditor.py +++ b/tests/test_auditor.py @@ -87,3 +87,113 @@ 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 From b29e0eee623dd9191fcf1283ffdf556197e3eab6 Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Wed, 20 May 2026 15:05:41 -0400 Subject: [PATCH 2/2] style: ruff format test_auditor.py Co-Authored-By: Oz --- tests/test_auditor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_auditor.py b/tests/test_auditor.py index a1c94b8..d6ea30a 100644 --- a/tests/test_auditor.py +++ b/tests/test_auditor.py @@ -171,10 +171,12 @@ def test_yaml_mode_uses_testcases_json(self, tmp_path: Path) -> None: 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"}, - ]), + json.dumps( + [ + {"id": "TEST-NN-002a", "requirement_id": "REQ-NN-002"}, + {"id": "TEST-NN-002b", "requirement_id": "REQ-NN-002"}, + ] + ), encoding="utf-8", )