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) == []