Files
autocad-mcp-pro/tests/test_release_consistency.py
Umut CANandClaude Opus 5 ca1c79fd01
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
fix: seven defects an adversarial audit found, and the gates that missed them
Every one of these was reported ok/clean to the caller while being wrong.

Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
  TRACE and HATCH, none of which carry that attribute, so every hatch
  measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
  `filtered_by` asserted the filter had run, and COM (which reads ActiveX
  .Area) returned a different selection for the same call. It now answers
  from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
  1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
  and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
  `ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
  dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
  `xround(value, dimrnd)` whenever the attribute is present at all --
  `xround(33.333, 0.0)` is 33. So on any reopened file, including one this
  server saved, every dimension was rounded to a whole unit before DIMDEC
  formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
  *no* rounding; the sentinel is now discarded at every load site, and a real
  0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
  they died in the header: dim_decimals=-1 returned ok and then killed the
  next dimension with `ValueError: Format specifier missing precision` from
  inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
  Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).

Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
  have exactly ONE caller ... the policy is scoped to the CHANNEL". The
  premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
  build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
  treats a newline and ';' as segment separators -- so a `linetype=` argument
  carrying either injected further AutoCAD commands, and the 36-verb denylist
  never saw that channel. Guarded with the DXF symbol-name rule (the
  injection characters fall out of it rather than being enumerated) plus
  validate_path for the file argument, which was also the one path-taking
  tool reaching the filesystem unchecked. The policy comment is corrected.

Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
  checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
  rule 7 makes that call the gate an agent is told to trust. Only the
  quarantine now escapes; an ordinary broken check still degrades.
  `_check_dimension_legibility` also read `_doc` directly from the event loop
  thread, missing the gate and performing exactly the unsynchronised access
  the quarantine exists to prevent. `construction_clear` answered
  "deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
  1.0 mm critiqued clean. It now reads the effective dimension height and
  separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
  the ones that had drifted. The collected-test count (README said 1274, the
  tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
  passing plus 5 fixed is 26, not 24 -- and contradicted the report it
  linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
  already carried the dimension fixes and so never measured the v1.5.0 tag.
  Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.

Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
  PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
  shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
  absent. It now runs the same three lanes as ci.yml, Windows included.

1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:07:50 +03:00

336 lines
14 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 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"