Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b52197aa |
@@ -41,6 +41,13 @@ row carries a status:
|
||||
nothing. Rows, never prose — a consumer list in a note or verification
|
||||
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
|
||||
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.
|
||||
|
||||
## The writer judges; audits check that it happened
|
||||
|
||||
The ledger is filled from your answers, given while you still know them. When
|
||||
@@ -157,8 +164,8 @@ Three questions the ledger answers mechanically:
|
||||
it), `variant` with the why if the departure is deliberate. The write-path
|
||||
hint asks the same question in-band the moment such a shape is written.
|
||||
- **History** — `shape_history(project_id, path, symbol?)`: the current rows
|
||||
plus every `classified` / `vanished` / `reappeared` / `drifted` event with
|
||||
its commit — "instance of #N from <date>, re-judged variant of #M because
|
||||
plus every `classified` / `vanished` / `reappeared` / `drifted` / `moved`
|
||||
event with its commit — "instance of #N from <date>, re-judged variant of #M because
|
||||
R, vanished at C". Rows, not recollection.
|
||||
- **Recheck** — `list_shapes(project_id, flag="recheck")`: judged
|
||||
instances/variants whose body changed since judged. The judgment stands;
|
||||
|
||||
@@ -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",
|
||||
"refresh_pattern_coverage", "move_shapes",
|
||||
# design systems
|
||||
"create_design_system", "update_design_system", "delete_design_system",
|
||||
"create_design_token", "update_design_token", "delete_design_token",
|
||||
|
||||
@@ -263,7 +263,8 @@ 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"). Each event carries the commit the tree was read at.
|
||||
"recheck"), `moved` (move_shapes 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>,
|
||||
re-judged variant of #M at <at> because <reason>, vanished at <commit>".
|
||||
@@ -398,10 +399,38 @@ 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.
|
||||
|
||||
Rows 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).
|
||||
|
||||
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`.
|
||||
|
||||
Args:
|
||||
project_id: The project whose ledger moves. Requires write access.
|
||||
from_path: The file or directory the shapes were found at.
|
||||
to_path: Where it lives now. Neither path may contain the other.
|
||||
|
||||
Returns {"moved": N, "replaced": N, "kept": [{path, symbol, kind}]}.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
return await shape_ledger_svc.move_shapes(uid, project_id, from_path, to_path)
|
||||
|
||||
|
||||
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,
|
||||
stamps_to_review, move_shapes,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -311,8 +311,9 @@ class CodeShapeConsumer(Base):
|
||||
|
||||
|
||||
# What a shape's history records. Not "appeared" — first_seen and
|
||||
# created_at already say that on the row; history is for what CHANGED:
|
||||
SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted")
|
||||
# created_at already say that on the row; history is for what CHANGED.
|
||||
# A `moved` event carries the path the shape left; the row says where it went.
|
||||
SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted", "moved")
|
||||
|
||||
|
||||
class CodeShapeEvent(Base):
|
||||
@@ -321,7 +322,7 @@ class CodeShapeEvent(Base):
|
||||
"We used #N here from <date>, #M replaced it at commit C, reason R" is a
|
||||
question the ledger row alone cannot answer once it has moved on; this
|
||||
table keeps each judgment (status, snippet, who, why, at which commit)
|
||||
and each presence change (vanished / reappeared / drifted) as it
|
||||
and each presence change (vanished / reappeared / drifted / moved) as it
|
||||
happened. Denormalised path/symbol/kind so a directory's history reads
|
||||
without joining; `snippet_id` is deliberately FK-free — history outlives
|
||||
the snippet it names, which is the point.
|
||||
|
||||
@@ -170,6 +170,85 @@ 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:
|
||||
"""Carry the ledger rows under ``from_path`` (a file or a directory) to
|
||||
the same place under ``to_path``, in every repo of the project.
|
||||
|
||||
Rows are keyed by path, so a directory moved in the tree would otherwise
|
||||
vanish every judged row at the next sync and start the same shapes again,
|
||||
unclassified, under the new path. A moved row is the same row — its id,
|
||||
judgment, uses edges and history ride along — and a `moved` event
|
||||
records the path it left. A row already at the target (the sync ran
|
||||
first, or a provisional judgment landed there) is replaced when nobody
|
||||
judged it; a judged one stands, and the old row is left where it is and
|
||||
reported in ``kept``. Bodies are not compared here: the next sync does
|
||||
that as always, and asks for a recheck where a judged body changed.
|
||||
"""
|
||||
from sqlalchemy import or_
|
||||
|
||||
from grimoire.services import access
|
||||
|
||||
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):
|
||||
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")
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
def beneath(prefix: str):
|
||||
return select(CodeShape).where(
|
||||
CodeShape.project_id == project_id,
|
||||
or_(CodeShape.path == prefix, CodeShape.path.like(prefix + "/%")),
|
||||
)
|
||||
|
||||
# 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 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()
|
||||
}
|
||||
moved, replaced, kept = 0, 0, []
|
||||
for row in sources:
|
||||
dest = new + row.path[len(old):]
|
||||
there = targets.get((row.repo_key, dest, row.symbol, row.kind))
|
||||
if there is not None:
|
||||
if there.status not in _MECHANICAL_TODO:
|
||||
kept.append({"path": row.path, "symbol": row.symbol, "kind": row.kind})
|
||||
continue
|
||||
# The target is the live sighting when the sync got there
|
||||
# first; the moved row takes over its presence.
|
||||
row.vanished_at = there.vanished_at
|
||||
row.last_seen_commit = there.last_seen_commit or row.last_seen_commit
|
||||
await session.delete(there)
|
||||
await session.flush()
|
||||
replaced += 1
|
||||
session.add(CodeShapeEvent(
|
||||
shape_id=row.id, project_id=project_id,
|
||||
path=row.path, symbol=row.symbol, kind=row.kind,
|
||||
event="moved", status=row.status, snippet_id=row.snippet_id,
|
||||
classified_by=row.classified_by, reason=row.reason,
|
||||
commit=row.last_seen_commit or "", at=now,
|
||||
))
|
||||
row.path = dest
|
||||
moved += 1
|
||||
await session.commit()
|
||||
return {"moved": moved, "replaced": replaced, "kept": kept}
|
||||
|
||||
|
||||
def _event(row: CodeShape, event: str, at: datetime, *, commit: str = "") -> CodeShapeEvent:
|
||||
"""A history row for a state change on ``row`` — status/snippet/by/reason
|
||||
are the row's CURRENT values, which for `classified` is the judgment
|
||||
|
||||
@@ -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