"""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 sys 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}" def test_windows_only_dependencies_carry_a_platform_marker() -> None: """`pip install autocad-mcp-pro[full]` must resolve on Linux and macOS. pywin32 publishes Windows wheels and **no sdist**, so an unmarked `pywin32>=306` in an extra makes that extra uninstallable everywhere else: pip fails at resolution with "no matching distribution" before it reaches anything else. `[full]` is the extra this project's own README recommends to contributors, and CI never caught it because every Linux lane installs `[pdf]` or uses uv.lock, whose resolution markers paper over the gap. """ extras = _pyproject()["project"]["optional-dependencies"] unmarked: list[str] = [] for extra, requirements in extras.items(): for requirement in requirements: name = re.split(r"[<>=!~\[; ]", requirement, maxsplit=1)[0].strip().lower() if name in {"pywin32"} and "sys_platform" not in requirement: unmarked.append(f"{extra}: {requirement}") assert not unmarked, ( 'Windows-only distributions need `; sys_platform == "win32"` or the ' f"extra cannot be installed off Windows: {unmarked}" ) def test_document_numbers_match_the_tree() -> None: """Every figure the docs quote, recomputed from its source of truth. The A/B suite grew 24 -> 26 and three files kept saying 24: `README.md` (fixed by hand), `benchmarks/README.md` and `CLAUDE.md` (not). Each was edited on its own and nobody re-read the other two, so the repository shipped three different answers to one question. That is a mechanical failure and it deserves a mechanical gate rather than another careful reader. `scripts/check_doc_numbers.py` also fails when a pattern stops matching, so rewording the sentence around a number cannot silently disarm the check. """ import subprocess completed = subprocess.run( [sys.executable, str(ROOT / "scripts" / "check_doc_numbers.py")], capture_output=True, text=True, cwd=ROOT, timeout=120, ) assert completed.returncode == 0, completed.stdout + completed.stderr def test_pre_commit_ruff_matches_the_project_pin() -> None: """A pre-commit hook that formats differently from CI is a trap, armed. `.pre-commit-config.yaml` sat at ruff v0.8.6 while `[dependency-groups] dev` pinned 0.16.1, and 0.16 formats Python inside Markdown where 0.8 does not. Anyone who ran `pre-commit install` therefore produced commits that failed CI's own `ruff format --check .` -- the same class of failure the ruff pin was introduced to end. """ config = (ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8") hook = re.search(r"ruff-pre-commit\s*\n\s*rev:\s*v?([\d.]+)", config) assert hook, ".pre-commit-config.yaml no longer pins ruff-pre-commit" dev_group = _pyproject()["dependency-groups"]["dev"] pinned = next((d for d in dev_group if d.startswith("ruff==")), None) assert pinned, "pyproject no longer pins ruff in [dependency-groups] dev" assert hook.group(1) == pinned.split("==", 1)[1], ( f"pre-commit runs ruff {hook.group(1)} but the project pins {pinned} — " "a contributor's hook would format differently from CI" ) def test_the_release_gate_covers_the_platforms_ci_covers() -> None: """`release.yml` publishes to PyPI; a lane CI has and it lacks is a hole. Its test job was one ubuntu/3.11 run, so a Windows-only defect could reach PyPI with no Windows job having executed. v1.5.0 shipped exactly such a defect (`except pywintypes.com_error` -> NameError without pywin32). """ release = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") gate = release.split("build:", 1)[0] assert "windows-latest" in gate, "the release test gate never runs on Windows" assert "3.12" in gate, "the release test gate never runs on Python 3.12" assert "uv sync --locked" in gate, "the release gate must install from the lockfile"