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
55 changes: 32 additions & 23 deletions validation/engines/python_checks/version_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,35 @@
r"(?:-(?P<pre>[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"/(?P<version>v[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<api_name>[^/]+)/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,
)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
70 changes: 61 additions & 9 deletions validation/tests/test_python_checks_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -423,20 +439,56 @@ 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)
assert len(findings) == 1
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) == []
Expand All @@ -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)
Expand All @@ -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) == []
Expand Down Expand Up @@ -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)
Expand All @@ -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) == []
Expand Down