Files
autocad-mcp-pro/tests/test_release_consistency.py
T
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:45:20 +03:00

217 lines
9.1 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_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}"