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