diff --git a/validation/engines/python_checks/version_checks.py b/validation/engines/python_checks/version_checks.py index 409cc15..d005079 100644 --- a/validation/engines/python_checks/version_checks.py +++ b/validation/engines/python_checks/version_checks.py @@ -24,28 +24,35 @@ r"(?:-(?P
[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*))?$"
 )
 
-# Extracts the version segment from a server URL path.
-# Matches the last path component starting with "v".
-# e.g. "https://example.com/qod/v1" -> "v1"
-#      "{apiRoot}/quality-on-demand/v0.2alpha2" -> "v0.2alpha2"
-_URL_VERSION_RE = re.compile(r"/(?Pv[a-z0-9.]+)/?$", re.IGNORECASE)
-
 # Extracts the api-name segment from a server URL (segment before version).
 # e.g. "{apiRoot}/quality-on-demand/v1" -> "quality-on-demand"
 _URL_API_NAME_RE = re.compile(r"/(?P[^/]+)/v[a-z0-9.]+/?$", re.IGNORECASE)
 
-# Matches a CAMARA-shaped version segment inside a scenario-step URL path:
-# an api-name segment (lowercase letters/digits/hyphens) followed by
-# either ``v{segment}`` or bare ``wip`` as the next segment. Used by P-025
-# to locate the version-bearing portion of a URL anywhere in a feature-
-# file line (not just at the end of the URL).
-# e.g. ``/quality-on-demand/vwip/sessions`` -> captured segment ``vwip``
-#      ``/qod/v1/sessions``                 -> captured ``v1``
-#      ``/device-status/wip/status``        -> captured ``wip``
-_STEP_URL_VERSION_RE = re.compile(
-    r"/[a-z0-9\-]+/(v[a-z0-9.]+|wip)(?=/|\s|$)",
-    re.IGNORECASE,
-)
+
+def _version_segment_pattern(api_name: str) -> re.Pattern[str]:
+    """Matcher for the path segment immediately after ``api_name`` in a URL.
+
+    CAMARA URLs are ``{apiRoot}/{api-name}/{version}/…`` (server URLs) and
+    ``/{api-name}/{version}/…`` (feature-file scenario steps), so the version
+    segment is whatever follows the api-name.  Group 1 captures that segment
+    *verbatim* — a correct ``v…``, a bare ``wip``, a typo, or a hyphenated
+    ``v0.1-eri`` — leaving the conformance decision to the caller instead of
+    baking a version charset into the regex.  (The old capture was blind to
+    hyphens: ``v0.1-eri`` matched only ``v0.1`` and the trailing ``-`` failed
+    the lookahead, so the segment was silently skipped — tooling#367.)  Shared
+    by P-004 (``check_server_url_version``) and P-025
+    (``check_feature_file_url_version``) so the two version checks cannot drift.
+
+    The ``/`` required right after ``api_name`` keeps the match off a longer
+    sibling (``dedicated-network`` will not match inside
+    ``dedicated-network-areas``); the left boundary keeps it off a longer word
+    that merely ends with the api-name.  A *misspelled* api-name is therefore
+    not located — a recorded residual, backstopped by the pre-snapshot wip gate.
+    """
+    return re.compile(
+        r'(?:^|[/\s"\'])' + re.escape(api_name) + r'/([^/\s"\']+)',
+        re.IGNORECASE,
+    )
 
 
 # ---------------------------------------------------------------------------
@@ -208,10 +215,11 @@ def check_server_url_version(
     if not isinstance(servers, list):
         return []
 
+    seg_re = _version_segment_pattern(api.api_name)
     findings: List[dict] = []
-    for i, server in enumerate(servers):
+    for server in servers:
         url = server.get("url", "") if isinstance(server, dict) else ""
-        m = _URL_VERSION_RE.search(url)
+        m = seg_re.search(url)
         if m is None:
             findings.append(
                 make_finding(
@@ -228,8 +236,8 @@ def check_server_url_version(
             )
             continue
 
-        actual_segment = m.group("version").lower()
-        if actual_segment != expected_segment.lower():
+        actual_segment = m.group(1)
+        if actual_segment.lower() != expected_segment.lower():
             findings.append(
                 make_finding(
                     engine_rule="check-server-url-version",
@@ -300,6 +308,7 @@ def check_feature_file_url_version(
 
     findings: List[dict] = []
     expected_lower = expected_segment.lower()
+    seg_re = _version_segment_pattern(api.api_name)
 
     for feature_file in matching:
         try:
@@ -309,7 +318,7 @@ def check_feature_file_url_version(
             continue
 
         for line_number, line in enumerate(lines, start=1):
-            for match in _STEP_URL_VERSION_RE.finditer(line):
+            for match in seg_re.finditer(line):
                 actual_segment = match.group(1)
                 if actual_segment.lower() == expected_lower:
                     continue
diff --git a/validation/tests/test_python_checks_version.py b/validation/tests/test_python_checks_version.py
index 369f50a..24d937f 100644
--- a/validation/tests/test_python_checks_version.py
+++ b/validation/tests/test_python_checks_version.py
@@ -273,6 +273,22 @@ def test_no_version_in_url(self, tmp_path: Path):
         assert len(findings) == 1
         assert "no recognisable version" in findings[0]["message"]
 
+    def test_hyphenated_segment_reports_mismatch(self, tmp_path: Path):
+        """Regression (tooling#367): a hyphenated server-URL segment must read
+        as a mismatch against the expected segment, not the misleading
+        'no recognisable version segment'.  A valid segment never has a hyphen
+        (build_version_segment strips them), so v0.1-eri is always malformed."""
+        _write_spec(
+            tmp_path, "qod", "1.0.0",
+            server_urls=["{apiRoot}/qod/v0.1-eri"],
+        )
+        ctx = _make_context("qod")
+        findings = check_server_url_version(tmp_path, ctx)
+        assert len(findings) == 1
+        assert "v0.1-eri" in findings[0]["message"]
+        assert "no recognisable" not in findings[0]["message"]
+        assert "v1" in findings[0]["message"]
+
     def test_no_servers(self, tmp_path: Path):
         _write_spec(tmp_path, "qod", "1.0.0")
         ctx = _make_context("qod")
@@ -394,7 +410,7 @@ def test_main_vwip_scenarios_pass(self, tmp_path: Path):
             tmp_path, "qod.feature",
             "Feature: QoD, vwip\n"
             "  Scenario: Create session\n"
-            "    When I send a POST to /quality-on-demand/vwip/sessions\n"
+            "    When I send a POST to /qod/vwip/sessions\n"
             "    Then the status code is 201\n",
         )
         ctx = _make_context("qod", branch_type="main", version="wip")
@@ -407,7 +423,7 @@ def test_main_bare_wip_in_url_is_error(self, tmp_path: Path):
             tmp_path, "qod.feature",
             "Feature: QoD, vwip\n"
             "  Scenario: Create session\n"
-            "    When I send a POST to /quality-on-demand/wip/sessions\n",
+            "    When I send a POST to /qod/wip/sessions\n",
         )
         ctx = _make_context("qod", branch_type="main", version="wip")
         findings = check_feature_file_url_version(tmp_path, ctx)
@@ -423,7 +439,7 @@ def test_main_wrong_version_segment_is_error(self, tmp_path: Path):
         _write_feature(
             tmp_path, "qod.feature",
             "Feature: QoD, vwip\n"
-            "  When I send a POST to /quality-on-demand/v1/sessions\n",
+            "  When I send a POST to /qod/v1/sessions\n",
         )
         ctx = _make_context("qod", branch_type="main", version="wip")
         findings = check_feature_file_url_version(tmp_path, ctx)
@@ -431,12 +447,48 @@ def test_main_wrong_version_segment_is_error(self, tmp_path: Path):
         assert "/v1" in findings[0]["message"]
         assert "/vwip" in findings[0]["message"]
 
+    def test_main_hyphenated_segment_is_error(self, tmp_path: Path):
+        """Regression (tooling#367): a hyphenated segment (v0.1-eri) must be
+        flagged, not silently skipped.  Real incident: it passed P-025 on the
+        PR but blocked the pre-snapshot wip gate."""
+        _write_spec(tmp_path, "dedicated-network-areas", "wip")
+        _write_feature(
+            tmp_path, "dedicated-network-areas.feature",
+            "Feature: DNA, vwip\n"
+            '  Given the resource "/dedicated-network-areas/v0.1-eri/areas"\n',
+        )
+        ctx = _make_context(
+            "dedicated-network-areas", branch_type="main", version="wip",
+        )
+        findings = check_feature_file_url_version(tmp_path, ctx)
+        assert len(findings) == 1
+        assert "v0.1-eri" in findings[0]["message"]
+        assert "/vwip" in findings[0]["message"]
+        assert findings[0]["line"] == 2
+
+    def test_main_misspelled_api_name_skipped(self, tmp_path: Path):
+        """Recorded residual: a misspelled api-name in the URL is not located
+        (the anchor is the known api-name), so the version segment is not
+        checked here — the pre-snapshot wip gate backstops it.  Anchoring on
+        the api-name keeps a false positive off correct PRs, which matters more
+        for an always-error gate than closing this compound-typo case."""
+        _write_spec(tmp_path, "dedicated-network-areas", "wip")
+        _write_feature(
+            tmp_path, "dedicated-network-areas.feature",
+            "Feature: DNA, vwip\n"
+            '  Given the resource "/dedicated-netwrok-areas/v0.1-eri/areas"\n',
+        )
+        ctx = _make_context(
+            "dedicated-network-areas", branch_type="main", version="wip",
+        )
+        assert check_feature_file_url_version(tmp_path, ctx) == []
+
     def test_release_matching_version_passes(self, tmp_path: Path):
         _write_spec(tmp_path, "qod", "1.0.0")
         _write_feature(
             tmp_path, "qod.feature",
             "Feature: QoD, v1.0.0\n"
-            "  When I send a POST to /quality-on-demand/v1/sessions\n",
+            "  When I send a POST to /qod/v1/sessions\n",
         )
         ctx = _make_context("qod", branch_type="release", version="1.0.0")
         assert check_feature_file_url_version(tmp_path, ctx) == []
@@ -448,7 +500,7 @@ def test_release_wrong_version_is_error(self, tmp_path: Path):
         _write_feature(
             tmp_path, "qod.feature",
             "Feature: QoD, v1.0.0\n"
-            "  When I send a POST to /quality-on-demand/vwip/sessions\n",
+            "  When I send a POST to /qod/vwip/sessions\n",
         )
         ctx = _make_context("qod", branch_type="release", version="1.0.0")
         findings = check_feature_file_url_version(tmp_path, ctx)
@@ -462,7 +514,7 @@ def test_initial_version_minor_segment(self, tmp_path: Path):
         _write_feature(
             tmp_path, "qod.feature",
             "Feature: QoD, v0.3.0\n"
-            "  When I send a POST to /quality-on-demand/v0.3/sessions\n",
+            "  When I send a POST to /qod/v0.3/sessions\n",
         )
         ctx = _make_context("qod", branch_type="release", version="0.3.0")
         assert check_feature_file_url_version(tmp_path, ctx) == []
@@ -496,8 +548,8 @@ def test_multiple_lines_collect_all_findings(self, tmp_path: Path):
         _write_feature(
             tmp_path, "qod.feature",
             "Feature: QoD, vwip\n"
-            "  When I send a POST to /quality-on-demand/v1/sessions\n"
-            "  And I send a GET to /quality-on-demand/wip/sessions/{id}\n",
+            "  When I send a POST to /qod/v1/sessions\n"
+            "  And I send a GET to /qod/wip/sessions/{id}\n",
         )
         ctx = _make_context("qod", branch_type="main", version="wip")
         findings = check_feature_file_url_version(tmp_path, ctx)
@@ -510,7 +562,7 @@ def test_spec_missing_returns_no_findings(self, tmp_path: Path):
         _write_feature(
             tmp_path, "qod.feature",
             "Feature: QoD, vwip\n"
-            "  When I send a POST to /quality-on-demand/wip/sessions\n",
+            "  When I send a POST to /qod/wip/sessions\n",
         )
         ctx = _make_context("qod", branch_type="main", version="wip")
         assert check_feature_file_url_version(tmp_path, ctx) == []