feat(6243): move_path carries a moved path's snippets with its shapes
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 31s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 31s
Snippet locations are keyed by path too, so a snippet recorded under a moved directory reads `missing` at its next check. The tool is now move_path(project_id, from_path, to_path). It moves the ledger rows as before, and re-paths the project's snippet locations under from_path through update_snippet, so each body and its indexed mirror stay one record. A snippet the caller can only read is listed in snippets_read_only. The tool refuses only when nothing at all is recorded under from_path. The path test is the one snippets already uses (_path_touches), and the plugin version is minted for the skill edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "grimoire",
|
||||
"description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).",
|
||||
"version": "2026.10.21.2344",
|
||||
"version": "2026.10.22.1907",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
@@ -42,8 +42,9 @@ row carries a status:
|
||||
detail cannot be sorted, queried, or diffed.
|
||||
|
||||
Rows are keyed by repo and path, so a move in the tree is a move in the
|
||||
ledger. When a file or directory moves, run `move_shapes(project_id,
|
||||
from_path, to_path)` before the next refresh. For a repo renamed on its
|
||||
ledger. When a file or directory moves, run `move_path(project_id,
|
||||
from_path, to_path)` before the next refresh; it carries the snippets
|
||||
recorded there too. For a repo renamed on its
|
||||
forge, run `bind_repo(new_url, project_id, moved_from=old_url)`. Otherwise
|
||||
the refresh marks every judged row vanished and lists the same shapes again,
|
||||
unjudged.
|
||||
|
||||
@@ -200,7 +200,7 @@ _WRITE_TOOLS = frozenset({
|
||||
"merge_snippets", "unmerge_snippet",
|
||||
"create_process", "update_process", "delete_process",
|
||||
"classify_shapes", "classify_shapes_by_rule", "confirm_shape_proposals",
|
||||
"refresh_pattern_coverage", "move_shapes",
|
||||
"refresh_pattern_coverage", "move_path",
|
||||
# design systems
|
||||
"create_design_system", "update_design_system", "delete_design_system",
|
||||
"create_design_token", "update_design_token", "delete_design_token",
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
from grimoire.mcp._context import current_user_id
|
||||
from grimoire.services import coverage as coverage_svc
|
||||
from grimoire.services import shape_ledger as shape_ledger_svc
|
||||
from grimoire.services import snippets as snippets_svc
|
||||
|
||||
|
||||
async def classify_shapes(
|
||||
@@ -263,7 +264,7 @@ async def shape_history(
|
||||
snippet_id, who, why — one per judgment, so a shape that was an instance
|
||||
of #N and later a variant of #M shows both), `vanished`, `reappeared`,
|
||||
`drifted` (the body moved under a judgment; see list_shapes flag=
|
||||
"recheck"), `moved` (move_shapes carried the row; the event's `path`
|
||||
"recheck"), `moved` (move_path carried the row; the event's `path`
|
||||
is the one it left). Each event carries the commit the tree was read at.
|
||||
|
||||
Read it as a timeline: "instance of #N from <first classified at>,
|
||||
@@ -399,38 +400,50 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def move_shapes(project_id: int, from_path: str, to_path: str) -> dict:
|
||||
"""Carry the judgments of a file or directory that moved in the tree.
|
||||
async def move_path(project_id: int, from_path: str, to_path: str) -> dict:
|
||||
"""Carry what the record knows about a file or directory that moved in
|
||||
the tree: its shape judgments and its snippets' recorded locations.
|
||||
|
||||
Rows are keyed by path. After `git mv src/old src/new`, the next coverage
|
||||
Both are keyed by path. After `git mv src/old src/new`, the next coverage
|
||||
refresh would mark every row under src/old vanished and list the same
|
||||
shapes again under src/new, unclassified, with every judgment left
|
||||
behind. Run this once the move is committed, before that refresh:
|
||||
each row under `from_path` is re-keyed to the same place under
|
||||
`to_path`, in every repo bound to the project, keeping its judgment,
|
||||
uses edges and history, and gaining a `moved` event (shape_history).
|
||||
behind. Each snippet recorded under src/old would also read `missing`.
|
||||
Run this once the move is committed, before that refresh.
|
||||
- Every ledger row under `from_path` is re-keyed to the same place under
|
||||
`to_path`, in every repo bound to the project. It keeps its judgment,
|
||||
uses edges and history, and gains a `moved` event (shape_history).
|
||||
- Every snippet of the project recorded under `from_path` has its
|
||||
location re-pathed.
|
||||
|
||||
A row already at the new path is replaced when nobody judged it (the
|
||||
refresh ran first, or the sync stamped it `scoped`); a judged one stands,
|
||||
and the old row is left behind and listed in `kept`. The refresh that
|
||||
follows compares bodies as always: a judged shape whose body changed in
|
||||
the move comes back flagged `recheck`.
|
||||
A ledger row already at the new path is replaced when nobody judged it
|
||||
(the refresh ran first, or the sync stamped it `scoped`). A judged one
|
||||
stands; the old row stays where it is and is listed in `kept`. The
|
||||
refresh that follows compares bodies as always, so a judged shape whose
|
||||
body changed in the move comes back flagged `recheck`.
|
||||
|
||||
Args:
|
||||
project_id: The project whose ledger moves. Requires write access.
|
||||
from_path: The file or directory the shapes were found at.
|
||||
project_id: The project whose record moves. Requires write access.
|
||||
from_path: The file or directory as it was recorded.
|
||||
to_path: Where it lives now. Neither path may contain the other.
|
||||
|
||||
Returns {"moved": N, "replaced": N, "kept": [{path, symbol, kind}]}.
|
||||
Returns {"moved": N, "replaced": N, "kept": [{path, symbol, kind}],
|
||||
"snippets_moved": [ids], "snippets_read_only": [ids]}. A snippet shared
|
||||
with you read-only stays where it is, and its owner moves it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
return await shape_ledger_svc.move_shapes(uid, project_id, from_path, to_path)
|
||||
out = await shape_ledger_svc.move_shapes(uid, project_id, from_path, to_path)
|
||||
snippets = await snippets_svc.move_locations(uid, project_id, from_path, to_path)
|
||||
out["snippets_moved"] = snippets["moved"]
|
||||
out["snippets_read_only"] = snippets["read_only"]
|
||||
if not (out["moved"] or out["kept"] or snippets["moved"] or snippets["read_only"]):
|
||||
raise ValueError(f"nothing in project {project_id} is recorded under `{from_path}`")
|
||||
return out
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
classify_shapes, classify_shapes_by_rule, list_shapes,
|
||||
refresh_pattern_coverage, confirm_shape_proposals, shape_history,
|
||||
stamps_to_review, move_shapes,
|
||||
stamps_to_review, move_path,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -170,10 +170,6 @@ async def sync_repo_shapes(
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _under(path: str, prefix: str) -> bool:
|
||||
return path == prefix or path.startswith(prefix + "/")
|
||||
|
||||
|
||||
async def move_shapes(
|
||||
user_id: int, project_id: int, from_path: str, to_path: str,
|
||||
) -> dict:
|
||||
@@ -193,12 +189,13 @@ async def move_shapes(
|
||||
from sqlalchemy import or_
|
||||
|
||||
from grimoire.services import access
|
||||
from grimoire.services.snippets import _path_touches
|
||||
|
||||
old = (from_path or "").strip().strip("/")
|
||||
new = (to_path or "").strip().strip("/")
|
||||
if not old or not new:
|
||||
raise ValueError("from_path and to_path are both required")
|
||||
if _under(old, new) or _under(new, old):
|
||||
if _path_touches(old, new) or _path_touches(new, old):
|
||||
raise ValueError(f"`{old}` and `{new}` overlap; move to a path outside the source")
|
||||
if not await access.can_write_project(user_id, project_id):
|
||||
raise ValueError(f"project {project_id} not found or no write access")
|
||||
@@ -213,10 +210,8 @@ async def move_shapes(
|
||||
# LIKE reads `_` as a wildcard; the prefix test is the real filter.
|
||||
sources = [
|
||||
r for r in (await session.execute(beneath(old))).scalars().all()
|
||||
if _under(r.path, old)
|
||||
if _path_touches(old, r.path)
|
||||
]
|
||||
if not sources:
|
||||
raise ValueError(f"project {project_id} has no shapes under `{old}`")
|
||||
targets = {
|
||||
(r.repo_key, r.path, r.symbol, r.kind): r
|
||||
for r in (await session.execute(beneath(new))).scalars().all()
|
||||
|
||||
@@ -1280,6 +1280,52 @@ def _path_touches(recorded: str, changed: str) -> bool:
|
||||
return changed == recorded or changed.startswith(recorded + "/")
|
||||
|
||||
|
||||
async def move_locations(
|
||||
user_id: int, project_id: int, from_path: str, to_path: str,
|
||||
) -> dict:
|
||||
"""Re-path the recorded locations under ``from_path`` (a file or a
|
||||
directory) to the same place under ``to_path``, on every live snippet of
|
||||
the project the caller can edit.
|
||||
|
||||
The twin of the shape ledger's move: a snippet recorded at a path that
|
||||
moved reads `missing` at its next check, and its canonical stamp no longer
|
||||
covers the shape that moved. Each snippet is rewritten through
|
||||
update_snippet, so its body and indexed fields stay one record. A
|
||||
snippet the caller can only read is left as it is and listed in
|
||||
``read_only``.
|
||||
"""
|
||||
old = (from_path or "").strip().strip("/")
|
||||
new = (to_path or "").strip().strip("/")
|
||||
async with async_session() as session:
|
||||
notes = (
|
||||
await session.execute(
|
||||
select(Note).where(
|
||||
Note.project_id == project_id,
|
||||
Note.note_type == SNIPPET_NOTE_TYPE,
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
moved: list[int] = []
|
||||
read_only: list[int] = []
|
||||
for note in notes:
|
||||
locations = snippet_fields(note).get("locations") or []
|
||||
if not any(_path_touches(old, loc.get("path") or "") for loc in locations):
|
||||
continue
|
||||
repathed = [
|
||||
{**loc, "path": new + loc["path"].strip("/")[len(old):]}
|
||||
if _path_touches(old, loc.get("path") or "") else loc
|
||||
for loc in locations
|
||||
]
|
||||
try:
|
||||
await update_snippet(user_id, int(note.id), locations=repathed)
|
||||
except PermissionError:
|
||||
read_only.append(int(note.id))
|
||||
continue
|
||||
moved.append(int(note.id))
|
||||
return {"moved": moved, "read_only": read_only}
|
||||
|
||||
|
||||
async def invalidate_for_push(
|
||||
repo_key: str,
|
||||
changed: list[str],
|
||||
|
||||
@@ -115,12 +115,35 @@ async def test_a_judgment_already_at_the_new_path_stands(seeded):
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_overlapping_empty_and_unwritable_moves_are_refused(seeded):
|
||||
async def test_overlapping_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")
|
||||
assert await move_shapes(owner, pid, "src/gone", "src/new") == {
|
||||
"moved": 0, "replaced": 0, "kept": [],
|
||||
}
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_the_snippets_recorded_there_move_with_it(seeded):
|
||||
from grimoire.services import snippets as snippets_svc
|
||||
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
note = await snippets_svc.create_snippet(
|
||||
owner, name="move_helper", code="def helper():\n pass\n",
|
||||
language="python", project_id=pid, locations=[
|
||||
{"repo": "Widget", "path": "src/old/sub/util.py", "symbol": "helper"},
|
||||
{"repo": "Widget", "path": "src/older/keep.py", "symbol": "untouched"},
|
||||
],
|
||||
)
|
||||
|
||||
result = await snippets_svc.move_locations(owner, pid, "src/old", "src/new")
|
||||
|
||||
assert result == {"moved": [int(note.id)], "read_only": []}
|
||||
moved = await snippets_svc.get_snippet(owner, int(note.id))
|
||||
assert [loc["path"] for loc in snippets_svc.snippet_fields(moved)["locations"]] == [
|
||||
"src/new/sub/util.py", "src/older/keep.py",
|
||||
]
|
||||
|
||||
@@ -383,12 +383,12 @@ 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():
|
||||
def test_move_path_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")
|
||||
tool = build_mcp_server()._tool_manager.get_tool("move_path")
|
||||
assert {"from_path", "to_path"} <= set(tool.parameters.get("properties", {}))
|
||||
assert "move_shapes" in _WRITE_TOOLS
|
||||
assert "move_path" in _WRITE_TOOLS
|
||||
|
||||
|
||||
def test_coverage_routes_are_registered():
|
||||
|
||||
Reference in New Issue
Block a user