fix(task-21): registry identity is normcase(abspath) everywhere — a case variant reloads instead of registering twice, resolves instead of being refused, and drawing_close(save) checks the file it writes

aeab950 claimed one registry entry per file but only the two save paths
compared with normcase. _register_document keyed by the plain
os.path.abspath string, so on Windows drawing_open of an already-open
file spelled with another case registered a second entry (no `reloaded`,
open_documents: 2); from there every document_activate/close of either
entry was refused as ambiguous, and drawing_close(save=True) — which
bypasses the resolver — wrote the second entry's bytes to the shared
file while the surviving row reported saved: True. The resolver's
by_path route had the same plain-string comparison, so over the wire a
lowercase drive letter was refused 'no open document named ...' while
ComBackend._com_find_document lowercases and accepts it.

One helper, _file_identity(path) = normcase(abspath(path)), now decides
every registry comparison: _register_document consults _path_holder so
another spelling of an open file reloads it; the _doc_path setter re-keys
only when the file changes, so a case-variant save keeps the key a caller
holds; _resolve_document_key's by_path compares identities;
_close_document_sync refuses at the write when another entry holds the
file; _clear_quarantine's same-path warning uses the same rule. Keys stay
spelled as the caller spelled them — the identity is for comparing, not
for showing.

Five tests pin it (four skip on a case-sensitive filesystem, where two
spellings of one file cannot exist), including the wire route through
fastmcp.Client that the review measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-16 21:51:16 +03:00
co-authored by Claude Opus 5
parent aeab950d2d
commit 2805697d16
2 changed files with 162 additions and 24 deletions
+109
View File
@@ -179,6 +179,91 @@ async def test_resolver_refuses_a_shared_path_even_on_an_exact_key_hit(backend,
assert len(await backend.document_list()) == 2, "a refusal closes nothing"
# Two spellings of one file only exist where the filesystem folds case;
# ``os.path.normcase`` is the identity on POSIX, so these tests skip there.
_case_folding_fs = pytest.mark.skipif(
os.path.normcase("A") == "A", reason="filesystem is case-sensitive"
)
@_case_folding_fs
async def test_opening_a_case_variant_of_an_open_file_reloads_it(backend, tmp_path):
"""Registration keys by file identity, not by spelling.
``drawing_open`` of an already-open file spelled with another case used to
register a *second* entry (key compared as plain strings), and from there
every lookup of either entry was refused as ambiguous.
"""
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 5) # unsaved edit
reopened = await backend.drawing_open(str(path).lower())
assert reopened["reloaded"] is True
assert reopened["open_documents"] == 1
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["entity_count"] == 0, "reload reads the disk"
assert (await backend.document_activate(str(path)))["ok"] is True
@_case_folding_fs
async def test_resolver_accepts_any_spelling_of_a_registered_path(backend, tmp_path):
"""``document_activate`` / ``document_close`` compare paths as the OS does.
The live engine lowercases before comparing (``_com_find_document``); a
lowercase drive letter or a case variant used to be refused headless as
'no open document named ...' - a dual-engine parity break.
"""
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.drawing_new()
spelled = str(path)
for variant in (
spelled.lower(),
spelled[0].lower() + spelled[1:],
spelled[0].upper() + spelled[1:],
spelled.upper(),
):
await backend.document_activate("untitled-2")
assert (await backend.document_activate(variant))["active"] == key, variant
assert len(await backend.document_list()) == 2
closed = await backend.document_close(spelled.lower())
assert closed["closed"] == key and closed["open_documents"] == 1
@_case_folding_fs
async def test_saving_to_a_case_variant_of_the_own_path_keeps_one_entry(backend, tmp_path):
path = tmp_path / "Self.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.entity_create_circle(0, 0, 1)
assert (await backend.drawing_save_as(str(path).lower()))["ok"] is True
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["saved"] is True
assert backend._active_key == key, "the same file keeps its key"
assert (await backend.document_activate(key))["ok"] is True
async def test_drawing_close_with_save_refuses_a_shared_path(backend, tmp_path):
"""``drawing_close`` bypasses the resolver, so the close itself must check.
With two entries on one file, ``drawing_close(save=True)`` wrote the active
entry's bytes over the file the other entry holds and reported
``saved: True`` - the surviving row then claimed the disk held its content.
"""
path = tmp_path / "dup.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_line(0, 0, 1, 1) # the owner is dirty
await backend.drawing_new()
await backend.entity_create_circle(0, 0, 5)
backend._docs[backend._active_key].path = str(path) # corrupt the registry behind its back
before = path.read_bytes()
with pytest.raises(ValueError, match="share the file"):
await backend.drawing_close(save=True)
assert path.read_bytes() == before, "a refusal writes nothing"
assert len(await backend.document_list()) == 2, "a refusal closes nothing"
async def test_activate_by_basename_full_path_or_key(backend, tmp_path):
path = tmp_path / "gear.dxf"
await backend.drawing_save(str(path))
@@ -303,6 +388,30 @@ async def test_document_list_tool_over_the_wire(monkeypatch):
assert closed["closed"] == "untitled-1" and closed["active"] == "untitled-2"
@_case_folding_fs
async def test_document_activate_accepts_a_lowercase_drive_over_the_wire(monkeypatch, tmp_path):
"""The wire canonicalises ``drawing_open`` paths (``validate_path`` resolves
them to disk casing) but not ``document_activate`` / ``document_close``
names, so a client spelling the drive letter in lowercase used to be
refused headless while the live engine accepted it."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
path = tmp_path / "Gear.dxf"
spelled = str(path)
lowered_drive = spelled[0].lower() + spelled[1:]
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
await client.call_tool("drawing_save", {"path": spelled})
await client.call_tool("drawing_new", {"bootstrap": False})
activated = (
await client.call_tool("document_activate", {"name_or_path": lowered_drive})
).structured_content
assert activated["name"] == "Gear.dxf" and activated["previous"] == "untitled-2"
closed = (
await client.call_tool("document_close", {"name_or_path": spelled.lower()})
).structured_content
assert closed["closed"] == os.path.abspath(spelled) and closed["open_documents"] == 1
# ── live engine, against a fake ActiveX surface ──────────────────────────────