feat(6243): a directory moved in the tree keeps its shape judgments
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Failing after 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m15s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 30s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Failing after 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m15s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 30s
Ledger rows are keyed by path, so after a directory moves the next sync marks every judged row vanished and lists the same shapes again, unjudged. move_shapes(project_id, from_path, to_path) re-keys the rows under one path to the other, in every repo of the project, keeping each row's judgment, uses edges and history and adding a `moved` event that records the path it left. A row already at the target is replaced when nobody judged it; a judged one stands, and the old row is reported in `kept`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""Real-Postgres integration tests for moving a directory's shape judgments.
|
||||
|
||||
Ledger rows are keyed by path. A directory moved in the tree has to carry its
|
||||
rows to the new path, or the next sync vanishes every judgment and lists the
|
||||
same shapes again, unjudged.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from grimoire.models import async_session
|
||||
from grimoire.models.code_shape import CodeShape, CodeShapeEvent
|
||||
from grimoire.models.project import Project
|
||||
from grimoire.services.shape_ledger import classify_shapes, move_shapes, sync_repo_shapes
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
REPO = "git.example.com/alice/widget"
|
||||
OLD_TREE = [
|
||||
("src/old/app.py", "sym", "make_app"),
|
||||
("src/old/sub/util.py", "sym", "helper"),
|
||||
("src/older/keep.py", "sym", "untouched"),
|
||||
]
|
||||
NEW_TREE = [
|
||||
("src/new/app.py", "sym", "make_app"),
|
||||
("src/new/sub/util.py", "sym", "helper"),
|
||||
("src/older/keep.py", "sym", "untouched"),
|
||||
]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seeded():
|
||||
"""A project whose ledger was synced before the move, one shape judged."""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "shape_move_owner")
|
||||
other = await ensure_user(s, "shape_move_other")
|
||||
project = Project(user_id=owner.id, title="Moved directory")
|
||||
s.add(project)
|
||||
await s.flush()
|
||||
ids = {"owner": owner.id, "other": other.id, "pid": project.id}
|
||||
await s.commit()
|
||||
|
||||
await sync_repo_shapes(ids["pid"], REPO, OLD_TREE, seen_marker="c1")
|
||||
await classify_shapes(ids["owner"], ids["pid"], [
|
||||
{"path": "src/old/sub/util.py", "symbol": "helper", "status": "exempt",
|
||||
"reason": "one caller"},
|
||||
])
|
||||
return ids
|
||||
|
||||
|
||||
async def _ledger(pid: int) -> dict[tuple[str, str], tuple[str, bool]]:
|
||||
async with async_session() as s:
|
||||
rows = await s.execute(select(CodeShape).where(CodeShape.project_id == pid))
|
||||
return {
|
||||
(r.path, r.symbol): (r.status, r.vanished_at is None)
|
||||
for r in rows.scalars()
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_the_judgments_move_and_the_next_sync_finds_them(seeded):
|
||||
pid = seeded["pid"]
|
||||
result = await move_shapes(seeded["owner"], pid, "src/old/", "src/new")
|
||||
|
||||
assert result == {"moved": 2, "replaced": 0, "kept": []}
|
||||
await sync_repo_shapes(pid, REPO, NEW_TREE, seen_marker="c2")
|
||||
assert await _ledger(pid) == {
|
||||
("src/new/app.py", "make_app"): ("unclassified", True),
|
||||
("src/new/sub/util.py", "helper"): ("exempt", True),
|
||||
("src/older/keep.py", "untouched"): ("unclassified", True),
|
||||
}
|
||||
async with async_session() as s:
|
||||
moved = (await s.execute(
|
||||
select(CodeShapeEvent.path).where(
|
||||
CodeShapeEvent.project_id == pid, CodeShapeEvent.event == "moved",
|
||||
)
|
||||
)).scalars().all()
|
||||
assert sorted(moved) == ["src/old/app.py", "src/old/sub/util.py"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_after_the_sync_got_there_first_the_judgment_replaces_the_fresh_row(seeded):
|
||||
pid = seeded["pid"]
|
||||
await sync_repo_shapes(pid, REPO, NEW_TREE, seen_marker="c2")
|
||||
|
||||
result = await move_shapes(seeded["owner"], pid, "src/old", "src/new")
|
||||
|
||||
assert result == {"moved": 2, "replaced": 2, "kept": []}
|
||||
assert await _ledger(pid) == {
|
||||
("src/new/app.py", "make_app"): ("unclassified", True),
|
||||
("src/new/sub/util.py", "helper"): ("exempt", True),
|
||||
("src/older/keep.py", "untouched"): ("unclassified", True),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_judgment_already_at_the_new_path_stands(seeded):
|
||||
pid = seeded["pid"]
|
||||
await sync_repo_shapes(pid, REPO, OLD_TREE + NEW_TREE, seen_marker="c2")
|
||||
await classify_shapes(seeded["owner"], pid, [
|
||||
{"path": "src/new/sub/util.py", "symbol": "helper", "status": "exempt",
|
||||
"reason": "judged again after the move"},
|
||||
])
|
||||
|
||||
result = await move_shapes(seeded["owner"], pid, "src/old", "src/new")
|
||||
|
||||
assert result["moved"] == 1 and result["replaced"] == 1
|
||||
assert result["kept"] == [
|
||||
{"path": "src/old/sub/util.py", "symbol": "helper", "kind": "sym"},
|
||||
]
|
||||
ledger = await _ledger(pid)
|
||||
assert ledger[("src/new/sub/util.py", "helper")] == ("exempt", True)
|
||||
assert ("src/old/sub/util.py", "helper") in ledger
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_overlapping_empty_and_unwritable_moves_are_refused(seeded):
|
||||
owner, other, pid = seeded["owner"], seeded["other"], seeded["pid"]
|
||||
with pytest.raises(ValueError, match="overlap"):
|
||||
await move_shapes(owner, pid, "src/old", "src/old/inner")
|
||||
with pytest.raises(ValueError, match="no shapes under"):
|
||||
await move_shapes(owner, pid, "src/gone", "src/new")
|
||||
with pytest.raises(ValueError, match="no write access"):
|
||||
await move_shapes(other, pid, "src/old", "src/new")
|
||||
assert ("src/old/app.py", "make_app") in await _ledger(pid)
|
||||
@@ -383,6 +383,14 @@ def test_bind_repo_tool_can_move_a_renamed_repo():
|
||||
assert "moved_from" in tool.parameters.get("properties", {})
|
||||
|
||||
|
||||
def test_move_shapes_is_mounted_as_a_write_tool():
|
||||
"""A directory moved in the tree carries its judgments to the new path."""
|
||||
from grimoire.mcp.server import _WRITE_TOOLS, build_mcp_server
|
||||
tool = build_mcp_server()._tool_manager.get_tool("move_shapes")
|
||||
assert {"from_path", "to_path"} <= set(tool.parameters.get("properties", {}))
|
||||
assert "move_shapes" in _WRITE_TOOLS
|
||||
|
||||
|
||||
def test_coverage_routes_are_registered():
|
||||
from grimoire.app import create_app
|
||||
|
||||
|
||||
@@ -642,5 +642,5 @@ def test_history_and_divergence_columns_are_pinned():
|
||||
fk = next(iter(ev.c.shape_id.foreign_keys))
|
||||
assert fk.ondelete == "CASCADE" and fk.column.table.name == "code_shapes"
|
||||
assert not ev.c.snippet_id.foreign_keys # history outlives the snippet
|
||||
assert SHAPE_EVENTS == ("classified", "vanished", "reappeared", "drifted")
|
||||
assert SHAPE_EVENTS == ("classified", "vanished", "reappeared", "drifted", "moved")
|
||||
assert "code_shape_events" in Base.metadata.tables
|
||||
|
||||
Reference in New Issue
Block a user