The v1.5.0 lock was generated before the version bump, so its own [[package]] entry said autocad-mcp-pro 1.4.0 while pyproject said 1.5.0. No dependency differed -- the transitive graph is byte-identical -- but `uv sync --locked` will not re-resolve, so lint, test-linux 3.11, test-linux 3.12 and test-windows all failed at install before running a single test. `uv lock` fixes it in one line. The gate that should have caught it did not exist: test_release_consistency chains pyproject -> version.py -> CHANGELOG -> README -> server.json, and the lockfile was the one release artifact carrying a version string nothing checked. Added test_uv_lock_records_the_pyproject_version, verified RED against the broken lock and green against the fixed one. Reproduced the CI lanes locally into a throwaway environment before pushing this time: `uv sync --locked --only-group dev`, then `--locked --extra pdf`, then ruff and pytest under `uv run --no-sync`. 1242 passed, 1 xfailed. README test-count snapshot 1242 -> 1243 for the new gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""Release-consistency gates (v1.4.0 Faz 0).
|
|
|
|
Locks the version string, the README release snapshot, the server.py section
|
|
headers, and the Dockerfile file selection to the actual state of the tree so
|
|
none of them can drift again:
|
|
|
|
- pyproject version == CHANGELOG top release == README snapshot major.minor
|
|
- version.py fallback parser returns the pyproject version
|
|
- README snapshot tool/resource/prompt counts == the generated tool inventory
|
|
- every ``SECTION n: ... (N tools)`` header matches the decorators below it
|
|
- server.json version == pyproject, and the README carries the mcp-name marker
|
|
- every wheel ``only-include`` entry is COPY'd into the Docker image
|
|
|
|
Two of these used to count the literal text ``@mcp.tool(`` in server.py, which
|
|
made that string load-bearing in prose: an example in a docstring inflated the
|
|
count. Neither does any more.
|
|
|
|
* The README snapshot counts now come from ``docs/tool-inventory.json``, which
|
|
``tests/test_tool_inventory.py`` holds equal to the live component registry —
|
|
so the chain README -> inventory -> registry is closed without this module
|
|
having to import the server.
|
|
* The section-header gate stays (a "SECTION n" is a fact about server.py's
|
|
layout that no runtime registry can see) but counts *parsed* decorators
|
|
instead of matched text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import re
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _pyproject() -> dict:
|
|
with (ROOT / "pyproject.toml").open("rb") as stream:
|
|
return tomllib.load(stream)
|
|
|
|
|
|
def _pyproject_version() -> str:
|
|
return str(_pyproject()["project"]["version"])
|
|
|
|
|
|
def _server_source() -> str:
|
|
return (ROOT / "server.py").read_text(encoding="utf-8")
|
|
|
|
|
|
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
|
|
|
|
def test_pyproject_version_is_semver() -> None:
|
|
assert SEMVER_RE.match(_pyproject_version()), (
|
|
f"pyproject version {_pyproject_version()!r} is not MAJOR.MINOR.PATCH"
|
|
)
|
|
|
|
|
|
def test_version_py_fallback_matches_pyproject(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The pyproject fallback in version.py must resolve the same version.
|
|
|
|
The installed-metadata short circuit is forced to miss so the test checks
|
|
our parser (the code path a source checkout without an install uses).
|
|
"""
|
|
import version as version_module
|
|
|
|
def _raise(_name: str) -> str:
|
|
raise version_module.PackageNotFoundError
|
|
|
|
monkeypatch.setattr(version_module, "version", _raise)
|
|
assert version_module.package_version() == _pyproject_version()
|
|
|
|
|
|
def test_changelog_top_release_matches_pyproject() -> None:
|
|
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
match = re.search(r"^## \[(\d+\.\d+\.\d+)\]", changelog, flags=re.MULTILINE)
|
|
assert match, "CHANGELOG.md has no '## [X.Y.Z]' release heading"
|
|
assert match.group(1) == _pyproject_version(), (
|
|
f"CHANGELOG top release {match.group(1)} != pyproject {_pyproject_version()}"
|
|
)
|
|
|
|
|
|
def _readme_snapshot() -> tuple[str, int, int, int]:
|
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
# The snapshot may wrap across blockquote lines; join continuations first.
|
|
flat = re.sub(r"\n>\s*", " ", readme)
|
|
match = re.search(
|
|
r"\*\*v(\d+\.\d+) release snapshot:\*\*\s*(\d+) tools\D+?(\d+) resources"
|
|
r"\D+?(\d+) prompt templates",
|
|
flat,
|
|
)
|
|
assert match, "README.md release-snapshot line not found or malformed"
|
|
return match.group(1), int(match.group(2)), int(match.group(3)), int(match.group(4))
|
|
|
|
|
|
def test_readme_snapshot_matches_pyproject_minor() -> None:
|
|
snapshot_minor, _, _, _ = _readme_snapshot()
|
|
expected = ".".join(_pyproject_version().split(".")[:2])
|
|
assert snapshot_minor == expected, (
|
|
f"README snapshot v{snapshot_minor} != pyproject major.minor v{expected}"
|
|
)
|
|
|
|
|
|
def test_readme_snapshot_counts_match_registrations() -> None:
|
|
"""README snapshot == the surface, via the generated inventory.
|
|
|
|
The counts used to be a regex tally of ``@mcp.tool(`` / ``@mcp.resource(`` /
|
|
``@mcp.prompt(`` in server.py's text. They now come from
|
|
``docs/tool-inventory.json``, which is built from the live component
|
|
registry and gated against it by tests/test_tool_inventory.py — so this
|
|
still fails when a registration is added without updating the README, but
|
|
it can no longer be moved by prose that merely *mentions* a decorator.
|
|
"""
|
|
inventory = json.loads((ROOT / "docs" / "tool-inventory.json").read_text(encoding="utf-8"))
|
|
totals = inventory["totals"]
|
|
live = (totals["tools"], totals["resources"], totals["prompts"])
|
|
_, readme_tools, readme_resources, readme_prompts = _readme_snapshot()
|
|
assert (readme_tools, readme_resources, readme_prompts) == live, (
|
|
f"README snapshot says {readme_tools}/{readme_resources}/{readme_prompts} "
|
|
f"(tools/resources/prompts) but the server registers {live[0]}/{live[1]}/{live[2]} "
|
|
"(per docs/tool-inventory.json; regenerate it with "
|
|
"`python scripts/generate_tool_inventory.py` if that is the stale one)"
|
|
)
|
|
|
|
|
|
def _tool_decorator_lines(src: str) -> list[int]:
|
|
"""Line number of every ``mcp.tool`` decorator, parsed rather than matched.
|
|
|
|
Parsing is what keeps the *string* ``@mcp.tool(`` from being load-bearing:
|
|
an occurrence in a docstring or comment is not a decorator node, so it
|
|
cannot inflate a section's count.
|
|
"""
|
|
lines: list[int] = []
|
|
for node in ast.walk(ast.parse(src)):
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
for decorator in node.decorator_list:
|
|
# Both spellings count: `@mcp.tool` and `@mcp.tool(...)`.
|
|
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
if (
|
|
isinstance(target, ast.Attribute)
|
|
and target.attr == "tool"
|
|
and isinstance(target.value, ast.Name)
|
|
and target.value.id == "mcp"
|
|
):
|
|
lines.append(decorator.lineno)
|
|
return sorted(lines)
|
|
|
|
|
|
def test_section_header_counts_match_decorators() -> None:
|
|
"""Kept, and not replaceable by the inventory: a section is a source-layout
|
|
fact (which tools sit under which header comment), invisible to the live
|
|
registry, and it does not line up with the tag-derived groups the inventory
|
|
records — SECTION 8 alone spans analysis + batch + templates + validation."""
|
|
src = _server_source()
|
|
lines = src.splitlines()
|
|
decorators = _tool_decorator_lines(src)
|
|
# Segment boundaries: every SECTION header (counted or not) ends a segment.
|
|
boundaries = [
|
|
number
|
|
for number, line in enumerate(lines, start=1)
|
|
if re.search(r"#[^\n]*SECTION [\w]+:", line)
|
|
]
|
|
boundaries.append(len(lines) + 1)
|
|
headers = [
|
|
(number, match.group(1), match.group(2))
|
|
for number, line in enumerate(lines, start=1)
|
|
if (match := re.search(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", line))
|
|
]
|
|
assert headers, "no counted SECTION headers found in server.py"
|
|
mismatches: list[str] = []
|
|
for start, section_id, declared in headers:
|
|
end = min(b for b in boundaries if b > start)
|
|
actual = sum(1 for line in decorators if start <= line < end)
|
|
if actual != int(declared):
|
|
mismatches.append(f"SECTION {section_id}: header says {declared}, actual {actual}")
|
|
assert not mismatches, "; ".join(mismatches)
|
|
|
|
|
|
def test_server_json_versions_match_pyproject() -> None:
|
|
"""MCP registry manifest must ship the same version as the package."""
|
|
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
|
|
expected = _pyproject_version()
|
|
assert manifest["version"] == expected, (
|
|
f"server.json version {manifest['version']} != pyproject {expected}"
|
|
)
|
|
for package in manifest["packages"]:
|
|
assert package["version"] == expected, (
|
|
f"server.json package {package['identifier']} version "
|
|
f"{package['version']} != pyproject {expected}"
|
|
)
|
|
if package["registryType"] == "pypi":
|
|
assert package["identifier"] == _pyproject()["project"]["name"]
|
|
|
|
|
|
def test_uv_lock_records_the_pyproject_version() -> None:
|
|
"""`uv sync --locked` refuses a lock that disagrees with pyproject.
|
|
|
|
This one is cheap and was learned the expensive way. The v1.5.0 lock was
|
|
generated before the version bump, so it recorded the project's own package
|
|
as 1.4.0 while pyproject said 1.5.0. No dependency differed — the whole
|
|
transitive graph was identical — but `--locked` will not re-resolve, so
|
|
every locked CI lane (lint, test-linux 3.11, test-linux 3.12, test-windows)
|
|
died on `uv sync` before running a single test.
|
|
|
|
The rest of this module chains pyproject -> version.py -> CHANGELOG ->
|
|
README -> server.json. The lockfile was the one release artifact carrying a
|
|
version string that nothing checked.
|
|
"""
|
|
lock = (ROOT / "uv.lock").read_text(encoding="utf-8")
|
|
name = _pyproject()["project"]["name"]
|
|
expected = _pyproject_version()
|
|
|
|
match = re.search(
|
|
rf'^\[\[package\]\]\nname = "{re.escape(name)}"\nversion = "([^"]+)"',
|
|
lock,
|
|
re.MULTILINE,
|
|
)
|
|
assert match, f"uv.lock has no [[package]] entry for {name!r}"
|
|
assert match.group(1) == expected, (
|
|
f"uv.lock records {name} {match.group(1)} but pyproject says {expected} — "
|
|
"run `uv lock` after bumping the version, or every locked CI lane fails "
|
|
"at `uv sync --locked` before a test runs"
|
|
)
|
|
|
|
|
|
def test_readme_contains_mcp_name_marker() -> None:
|
|
"""PyPI ownership validation for the MCP registry needs this marker in the
|
|
README (which ships to PyPI as the long description)."""
|
|
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
|
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
assert f"mcp-name: {manifest['name']}" in readme
|
|
|
|
|
|
def test_dockerfile_copies_every_wheel_include() -> None:
|
|
"""The Docker image must contain everything the wheel ships (GH bug: the
|
|
image previously missed engineering/ and version.py)."""
|
|
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
|
copy_lines = "\n".join(
|
|
line for line in dockerfile.splitlines() if line.strip().upper().startswith("COPY")
|
|
)
|
|
includes = _pyproject()["tool"]["hatch"]["build"]["targets"]["wheel"]["only-include"]
|
|
missing = [name for name in includes if name not in copy_lines]
|
|
assert not missing, f"Dockerfile COPY misses wheel-shipped paths: {missing}"
|