Files
FabledScribe/tests/test_integration_shape_classify.py
T
bvandeusenandClaude Fable 5 475f0857c9
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
feat(ledger): write-path stamping — a pulled canon the session then instantiates lands as a hook instance row (#2791, milestone 294 step 5)
The prior-art hook now names the shapes being written (shapes=kind:name —
every definition in the payload, or the one enclosing an Edit found by
walking the file upward) and the server stamps them as instance rows when
the session PULLED a snippet inside PULL_WINDOW that the payload references
by symbol or that the semantic arm scored for this very payload.
classified_by=hook, evidence in reason; never overrides a judgment or a
canonical row, overridable by classify_shapes. Offered-but-unopened stamps
nothing. Pulled-and-already-seen snippets stay in the semantic query as
evidence without re-entering the deduped menu. A brand-new shape gets a
provisional row the next sync confirms or vanishes. Read-scoped keys get
the hint, never the stamp. Plugin 0.1.34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:19:45 -04:00

352 lines
15 KiB
Python

"""Real-Postgres integration tests for shape classification (#2789).
What mocks can't prove: the all-or-nothing batch against real rows, the
write-ACL gate, the todo query's filters, and the consumer map riding
get_snippet. Ledger rows are seeded through the same sync the coverage walk
uses — no forge needed, the sync takes extracted shapes directly.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session, engine
from scribe.models.code_shape import CodeShape
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services.shape_ledger import (
classify_shapes,
list_project_shapes,
snippet_consumers,
sync_repo_shapes,
)
pytestmark = pytest.mark.integration
REPO = "git.example.com/alice/widget"
SHAPES = [
("src/app.py", "sym", "make_app"),
("src/app.py", "sym", "Config"),
("src/util.py", "sym", "helper"),
("web/button.css", "css", "btn"),
]
@pytest_asyncio.fixture(autouse=True)
async def _dispose_engine():
"""Dispose the app's module-level engine after each test.
The engine pools asyncpg connections per event loop, but pytest-asyncio runs
each test on a fresh loop — so without this, test 2 gets handed test 1's
connection bound to a now-dead loop ("Future attached to a different loop").
Disposing in the test's own loop teardown clears the pool cleanly.
"""
yield
await engine.dispose()
async def _user(session, username: str) -> User:
existing = (
await session.execute(select(User).where(User.username == username))
).scalar_one_or_none()
if existing is not None:
return existing
user = User(username=username)
session.add(user)
await session.flush()
return user
@pytest_asyncio.fixture
async def seeded():
"""Owner + outsider, a project with a synced 4-shape ledger, one snippet."""
from scribe.services import snippets as snippets_svc
async with async_session() as s:
owner = await _user(s, "classify_owner")
other = await _user(s, "classify_other")
project = Project(user_id=owner.id, title="Classify target")
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, SHAPES, seen_marker="main")
snippet = await snippets_svc.create_snippet(
ids["owner"], name="cls_make_app", code="def make_app():\n pass\n",
language="python", repo="Widget", path="src/factory.py",
symbol="factory", project_id=ids["pid"],
)
ids["snippet"] = int(snippet.id)
return ids
@pytest.mark.integration
async def test_classify_applies_judgments_and_reports_unmatched(seeded):
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
out = await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
{"path": "src/util.py", "symbol": "helper", "status": "exempt",
"reason": "test scaffolding, deliberately local"},
{"path": "web/button.css", "symbol": "btn", "status": "variant",
"snippet_id": sid, "reason": "darker focus ring for the toolbar"},
{"path": "gone.py", "symbol": "nothing", "status": "exempt",
"reason": "x"},
], via="audit")
assert out["classified"] == 3
assert out["unmatched"] == [{"path": "gone.py", "symbol": "nothing"}]
rows, total = await list_project_shapes(owner, pid)
by_symbol = {r.symbol: r for r in rows}
assert total == 4
assert by_symbol["make_app"].status == "instance"
assert by_symbol["make_app"].snippet_id == sid
assert by_symbol["make_app"].classified_by == "audit"
assert by_symbol["helper"].status == "exempt"
assert by_symbol["helper"].reason == "test scaffolding, deliberately local"
assert by_symbol["btn"].status == "variant"
assert by_symbol["Config"].status == "unclassified"
# Withdrawing a judgment returns the shape to the todo, fields cleared.
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "unclassified"},
])
rows, _ = await list_project_shapes(owner, pid, status="unclassified")
assert {r.symbol for r in rows} == {"Config", "make_app"}
make_app = next(r for r in rows if r.symbol == "make_app")
assert make_app.snippet_id is None and make_app.classified_by is None
@pytest.mark.integration
async def test_a_bad_batch_applies_nothing(seeded):
"""All-or-nothing (#2709's lesson): a caller must never learn later that
half a batch silently happened."""
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
with pytest.raises(ValueError) as err:
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
{"path": "src/util.py", "symbol": "helper", "status": "variant",
"snippet_id": sid}, # variant with no reason: structural error
])
assert "needs a reason" in str(err.value)
rows, _ = await list_project_shapes(owner, pid, status="unclassified")
assert len(rows) == 4 # including make_app — the valid half did NOT apply
# A snippet target the caller can't read is the same: nothing applies.
with pytest.raises(ValueError):
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": 999999999},
])
@pytest.mark.integration
async def test_classification_is_write_gated_and_listing_read_gated(seeded):
other, pid, sid = seeded["other"], seeded["pid"], seeded["snippet"]
with pytest.raises(ValueError):
await classify_shapes(other, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "exempt",
"reason": "not their call to make"},
])
assert await list_project_shapes(other, pid) == ([], 0)
@pytest.mark.integration
async def test_list_filters_compose(seeded):
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
])
rows, total = await list_project_shapes(owner, pid, path="src")
assert total == 3 and all(r.path.startswith("src/") for r in rows)
# Directory semantics, not string prefix: "sr" matches nothing.
assert (await list_project_shapes(owner, pid, path="sr"))[1] == 0
rows, total = await list_project_shapes(owner, pid, snippet_id=sid)
assert total == 1 and rows[0].symbol == "make_app"
rows, total = await list_project_shapes(
owner, pid, status="unclassified", limit=2
)
assert total == 3 and len(rows) == 2 # paged, with the true total
@pytest.mark.integration
async def test_get_snippet_carries_the_structured_consumer_map(seeded):
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.snippets import get_snippet
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
{"path": "web/button.css", "symbol": "btn", "status": "variant",
"snippet_id": sid, "reason": "darker focus ring"},
])
token = _user_id_ctx.set(owner)
try:
data = await get_snippet(snippet_id=sid)
finally:
_user_id_ctx.reset(token)
assert [i["path"] for i in data["instances"]] == ["src/app.py"]
assert data["variants"][0]["reason"] == "darker focus ring"
# The map is caller-scoped: an outsider asking the service directly gets
# silence, not another project's file layout.
consumers = await snippet_consumers(seeded["other"], sid)
assert consumers == {"instances": [], "variants": []}
@pytest.mark.integration
async def test_sync_refiles_rows_whose_snippet_was_purged(seeded):
"""The SET NULL companion (#2787): a judgment whose target is hard-deleted
rejoins the todo on the next sync instead of dangling target-less."""
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
])
from scribe.models.note import Note
async with async_session() as s:
note = await s.get(Note, sid)
await s.delete(note) # hard delete, as purge_trash would
await s.commit()
await sync_repo_shapes(pid, REPO, SHAPES, seen_marker="main")
async with async_session() as s:
row = (await s.execute(select(CodeShape).where(
CodeShape.project_id == pid, CodeShape.symbol == "make_app",
))).scalar_one()
assert row.status == "unclassified"
assert row.snippet_id is None
# --- #2791: the write-path feed lands hook evidence as rows -------------------
@pytest.mark.integration
async def test_write_path_stamp_is_evidence_that_yields_to_judgment(seeded):
"""Pulled + referenced → every named shape of the snippet's kind becomes
an instance row, classified_by=hook, carrying the evidence as reason. A
later agent judgment on one of them stands against a re-stamp; the hook
may only overwrite nobody's judgment or its own. The outsider stamps
nothing (write-gated like every other ledger write)."""
from datetime import datetime, timezone
from scribe.services.shape_ledger import stamp_write_path_instances
owner, other, pid, sid = (
seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
)
pulled = {sid: datetime.now(timezone.utc)}
code = "app = factory()\nreturn app\n" # references the snippet's symbol
assert await stamp_write_path_instances(
other, pid, path="src/app.py", shapes=[("sym", "make_app")],
code=code, pulled=pulled,
) == []
stamped = await stamp_write_path_instances(
owner, pid, path="src/app.py",
shapes=[("sym", "make_app"), ("sym", "Config"), ("css", "nope")],
code=code, pulled=pulled,
)
assert {s["symbol"] for s in stamped} == {"make_app", "Config"} # css skipped: no css canon
rows, _ = await list_project_shapes(owner, pid, snippet_id=sid)
by_symbol = {r.symbol: r for r in rows}
assert by_symbol["make_app"].status == "instance"
assert by_symbol["make_app"].classified_by == "hook"
assert by_symbol["make_app"].reason == f"hook: pulled #{sid}; payload references `factory`"
# A judgment lands; the next stamp must leave it alone but may re-stamp
# its own earlier row.
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "exempt",
"reason": "the app factory is its own thing"},
])
again = await stamp_write_path_instances(
owner, pid, path="src/app.py",
shapes=[("sym", "make_app"), ("sym", "Config")], code=code, pulled=pulled,
)
assert {s["symbol"] for s in again} == {"Config"}
rows, _ = await list_project_shapes(owner, pid, path="src/app.py")
by_symbol = {r.symbol: r for r in rows}
assert by_symbol["make_app"].status == "exempt"
assert by_symbol["Config"].status == "instance"
# Neither pulled nor in play → nothing, even with shapes named.
assert await stamp_write_path_instances(
owner, pid, path="src/util.py", shapes=[("sym", "helper")],
code="print('unrelated')", pulled=pulled,
) == []
@pytest.mark.integration
async def test_a_brand_new_shape_gets_a_provisional_row_the_sync_settles(seeded):
"""The shape being written right now has no ledger row yet. With the
hook's repo key it gets a provisional one — seen markers unset — so the
stamp survives until the next sync, which confirms it (sets the marker)
or stamps it vanished. Without a repo key only existing rows are touched."""
from datetime import datetime, timezone
from scribe.services.shape_ledger import stamp_write_path_instances
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
pulled = {sid: datetime.now(timezone.utc)}
code = "def build():\n return factory()\n"
assert await stamp_write_path_instances(
owner, pid, path="src/new.py", shapes=[("sym", "build")],
code=code, pulled=pulled, # no repo_key
) == []
stamped = await stamp_write_path_instances(
owner, pid, path="src/new.py", shapes=[("sym", "build")],
code=code, pulled=pulled, repo_key=REPO,
)
assert [s["symbol"] for s in stamped] == ["build"]
async with async_session() as s:
row = (await s.execute(select(CodeShape).where(
CodeShape.project_id == pid, CodeShape.path == "src/new.py",
))).scalar_one()
assert row.status == "instance" and row.classified_by == "hook"
assert row.first_seen_commit is None and row.last_seen_commit is None
# The sync sees the shape in the tree → confirmed, stamp intact.
await sync_repo_shapes(
pid, REPO, SHAPES + [("src/new.py", "sym", "build")], seen_marker="abc123",
)
rows, _ = await list_project_shapes(owner, pid, path="src/new.py")
assert rows[0].status == "instance" and rows[0].last_seen_commit == "abc123"
# The sync no longer sees it → vanished, out of the live accounting.
await sync_repo_shapes(pid, REPO, SHAPES, seen_marker="def456")
rows, _ = await list_project_shapes(owner, pid, path="src/new.py")
assert rows == []
rows, _ = await list_project_shapes(owner, pid, path="src/new.py", include_vanished=True)
assert rows[0].vanished_at is not None
@pytest.mark.integration
async def test_recent_pulls_reads_the_usage_stream(seeded):
"""The "actually pulled it" half is the PULLED usage event, inside the
window; a surfacing alone is not a pull."""
from datetime import datetime, timedelta, timezone
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
from scribe.services.shape_ledger import recent_pulls
owner, sid = seeded["owner"], seeded["snippet"]
now = datetime.now(timezone.utc)
async with async_session() as s:
s.add_all([
NoteUsageEvent(user_id=owner, note_id=sid, event=PULLED, source="mcp_get_snippet"),
NoteUsageEvent(user_id=owner, note_id=sid + 1000, event=SURFACED, source="auto_inject"),
NoteUsageEvent(user_id=owner, note_id=sid + 2000, event=PULLED,
source="mcp_get_snippet", created_at=now - timedelta(days=2)),
])
await s.commit()
pulls = await recent_pulls(owner)
assert sid in pulls
assert sid + 1000 not in pulls
assert sid + 2000 not in pulls