Files
FabledScribe/tests/test_integration_shape_classify.py
T
bvandeusenandClaude Opus 5 312dc9f6f7
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 22s
fix(shapes): the semantic arm proposes at the write-path floor, not a private 0.8 (#4208)
0.8 was sized for proposals nobody reads; every semantic proposal is read
by a judge before it is confirmed. Measured, it proposed 0 of 150 while
judged instances of a canon score 0.68-0.71 - the write-path hint's own
floor asks the same question of the same documents at 0.68. The arm now
uses that floor, scans 8 hits instead of 3 (a true instance ranked 4th
behind snippets of other language families), and the proposer version
bumps to 5 so rows examined under the old floor are read again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-22 08:33:01 -04:00

1063 lines
52 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
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,
classify_shapes_where,
list_project_shapes,
snippet_consumers,
sync_repo_shapes,
)
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
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
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 ensure_user(s, "classify_owner")
other = await ensure_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_rule_form_sweeps_unclassified_rows_only_and_applies_whole(seeded):
"""#2868: one judgment over a directory + glob; judged rows are left
alone unless include_judged; the same gates as the row form."""
owner, other, pid, sid = seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "settings holder"},
])
out = await classify_shapes_where(
owner, pid, path="src", status="instance", snippet_id=sid, via="audit",
)
# make_app + helper swept; Config (already judged) untouched; css not under src/.
assert out["classified"] == 2
assert out["sample"] == ["src/app.py::make_app", "src/util.py::helper"]
rows, _ = await list_project_shapes(owner, pid)
by_symbol = {r.symbol: r for r in rows}
assert by_symbol["make_app"].status == "instance" and by_symbol["make_app"].classified_by == "audit"
assert by_symbol["Config"].status == "exempt" and by_symbol["Config"].reason == "settings holder"
assert by_symbol["btn"].status == "unclassified"
# Glob + kind narrow; include_judged re-judges.
out = await classify_shapes_where(
owner, pid, path="web", status="exempt", pattern="btn*", kind="css",
reason="one toolbar button", include_judged=True,
)
assert out["classified"] == 1
out = await classify_shapes_where(
owner, pid, path="src", status="unclassified", include_judged=True,
)
assert out["classified"] == 3 # withdrawal sweeps judged rows when asked
# Gates: reason for exempt, snippet for instance, write access, a path.
with pytest.raises(ValueError):
await classify_shapes_where(owner, pid, path="src", status="exempt")
with pytest.raises(ValueError):
await classify_shapes_where(owner, pid, path="src", status="instance")
with pytest.raises(ValueError):
await classify_shapes_where(owner, pid, path="", status="exempt", reason="x")
with pytest.raises(ValueError):
await classify_shapes_where(other, pid, path="src", status="exempt", reason="x")
@pytest.mark.integration
async def test_sync_stamps_scoped_rows_and_unstamps_when_they_become_reachable(seeded):
"""#2869: by-construction one-offs arrive `scoped` (mechanical), count as
accounted, are reached by the sweep's default, and go back to the todo
if a later tree makes them ordinary. A judgment overrides the stamp."""
from scribe.services.coverage import ArchiveShape
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
scoped_shapes = [
ArchiveShape("web/Card.vue", "css", "card", ".card {", "s1", ".card { x: 1 }", True),
ArchiveShape("web/Card.vue", "sym", "load", "function load() {", "s2", "function load() {}", True),
ArchiveShape("src/util.py", "sym", "helper", "def helper():", "s3", "def helper(): pass", False),
]
await sync_repo_shapes(pid, REPO, scoped_shapes, seen_marker="main")
rows, _ = await list_project_shapes(owner, pid, path="web/Card.vue")
assert {r.status for r in rows} == {"scoped"}
assert all(r.classified_by == "mechanical" and "by construction" in (r.reason or "") for r in rows)
# The human todo excludes them; the sweep's default still reaches them.
assert (await list_project_shapes(owner, pid, status="unclassified", path="web/Card.vue"))[1] == 0
out = await classify_shapes_where(
owner, pid, path="web/Card.vue", status="instance", snippet_id=sid, kind="css",
)
assert out["classified"] == 1
# Re-synced as ordinary: the stamped sym returns to the todo; the
# judged css keeps its judgment.
plain = [ArchiveShape(s.path, s.kind, s.name, s.signature, s.body_sha, s.body, False) for s in scoped_shapes]
await sync_repo_shapes(pid, REPO, plain, seen_marker="main")
rows, _ = await list_project_shapes(owner, pid, path="web/Card.vue")
by_symbol = {r.symbol: r for r in rows}
assert by_symbol["load"].status == "unclassified" and by_symbol["load"].classified_by is None
assert by_symbol["card"].status == "instance" and by_symbol["card"].snippet_id == sid
@pytest.mark.integration
async def test_uses_edges_are_the_consumer_map(seeded):
"""#2870: a shape keeps ONE snippet_id (what it is) and any number of
uses edges (what it calls); the snippet's consumer map lists them,
list_shapes(uses=N) finds them, and a sweep can write them."""
from scribe.services import snippets as snippets_svc
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
helper = await snippets_svc.create_snippet(
owner, name="cls_hash_helper", code="def hash_token(raw):\n return raw\n",
language="python", repo="Widget", path="src/hash.py", symbol="hash_token",
project_id=pid,
)
hid = int(helper.id)
out = await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid, "uses": [hid]},
], via="audit")
assert out["classified"] == 1
rows, total = await list_project_shapes(owner, pid, uses=hid)
assert total == 1 and rows[0].symbol == "make_app" and rows[0].snippet_id == sid
consumers = await snippet_consumers(owner, hid)
assert consumers["instances"] == [] and len(consumers["uses"]) == 1
assert consumers["uses"][0]["symbol"] == "make_app" and consumers["uses"][0]["basis"] == "audit"
# A sweep writes uses too; an unknown snippet in uses applies nothing.
out = await classify_shapes_where(
owner, pid, path="src/util.py", status="exempt", reason="local", uses=[hid],
)
assert out["classified"] == 1
assert (await list_project_shapes(owner, pid, uses=hid))[1] == 2
# The proposer writes uses edges for JUDGED rows too: Config (exempt)
# names hash_token in its body → an edge, no proposal.
from scribe.services.shape_ledger import propose_for_repo
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "settings"},
])
defs = _defs(("src/app.py", "sym", "Config", "class Config:", "class Config:\n token = hash_token(raw)\n"))
with _quiet_semantic():
await propose_for_repo(owner, pid, REPO, defs)
rows, total = await list_project_shapes(owner, pid, uses=hid)
assert total == 3 and {r.symbol for r in rows} >= {"Config"}
cfg = next(r for r in rows if r.symbol == "Config")
assert cfg.status == "exempt" and cfg.proposal is None
with pytest.raises(ValueError):
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "x", "uses": [999999]},
])
@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": [], "uses": []}
@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 empty — 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"
# "Unset" is the column's empty default — the markers are non-null
# Text, and the sync is what first fills them.
assert row.first_seen_commit == "" and row.last_seen_commit == ""
# 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
# --- #2792: the mechanical proposer against real rows -------------------------
def _quiet_semantic():
"""The semantic basis needs the embedder; these tests prove the other
bases and the bookkeeping, so it answers "nothing" here."""
from unittest.mock import AsyncMock, patch
from scribe.services import shape_ledger
return patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None))
def _defs(*items):
"""ArchiveShape-like records: (path, kind, name, signature, body_sha, body)."""
import hashlib
out = []
for path, kind, name, signature, body in items:
sha = hashlib.sha1(" ".join(body.split()).encode()).hexdigest()[:16]
out.append((path, kind, name, signature, sha, body))
return out
@pytest.mark.integration
async def test_proposer_proposes_and_confirm_classifies(seeded):
"""Bodies in hand, the proposer records proposals on unclassified rows —
symbol (a second `factory` elsewhere), reference (a call site), and
nothing for the unrelated — skips rows whose content it already judged,
and a scoped confirm turns proposals into agent instances while a
classify on another retires its proposal."""
from scribe.services.shape_ledger import (
confirm_proposals, propose_for_repo,
)
owner, other, pid, sid = (
seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
)
defs = _defs(
("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n app = factory()\n return app"),
("src/app.py", "sym", "Config", "class Config:", "class Config:\n debug = False"),
("src/util.py", "sym", "helper", "def helper(x):", "def helper(x):\n return x"),
("src/dup.py", "sym", "factory", "def factory():", "def factory():\n return 1"),
("web/button.css", "css", "btn", ".btn {", ".btn {\n color: red;\n}"),
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="main")
with _quiet_semantic():
stats = await propose_for_repo(owner, pid, REPO, defs)
assert stats == {"examined": 5, "proposed": 2, "semantic_checked": 2}
rows, total = await list_project_shapes(owner, pid, proposal="canon")
by_symbol = {r.symbol: r for r in rows}
assert total == 2
assert by_symbol["factory"].proposal == {"basis": "symbol", "score": 1.0, "snippet_id": sid}
assert by_symbol["make_app"].proposal == {"basis": "reference", "score": 0.9, "snippet_id": sid}
rows, _ = await list_project_shapes(owner, pid, proposal="reference")
assert [r.symbol for r in rows] == ["make_app"]
# Same content again → nothing re-examined (the semantic cap would
# otherwise be spent on the same rows every refresh). A cap that leaves
# rows unreached leaves them UNexamined, so the next refresh gets them.
with _quiet_semantic():
assert (await propose_for_repo(owner, pid, REPO, defs))["examined"] == 0
await classify_shapes(owner, pid, [
{"path": "src/util.py", "symbol": "helper", "status": "unclassified"},
])
assert (await propose_for_repo(owner, pid, REPO, defs, semantic_cap=0))["semantic_checked"] == 0
assert (await propose_for_repo(owner, pid, REPO, defs))["examined"] == 1
# Outsider can't confirm; the owner confirms by snippet, scoped.
with pytest.raises(ValueError):
await confirm_proposals(other, pid, snippet_id=sid)
assert await confirm_proposals(owner, pid, basis="symbol") == {"confirmed": 1}
rows, _ = await list_project_shapes(owner, pid, snippet_id=sid)
factory = next(r for r in rows if r.symbol == "factory")
assert factory.status == "instance" and factory.classified_by == "agent"
assert factory.reason == "confirmed symbol proposal (1.00)"
assert factory.proposal is None
# A judgment on a proposed row retires the proposal; withdrawing a
# judgment forgets the examination so the next pass proposes afresh.
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "exempt", "reason": "bootstrap"},
])
rows, _ = await list_project_shapes(owner, pid, path="src/app.py")
make_app = next(r for r in rows if r.symbol == "make_app")
assert make_app.status == "exempt" and make_app.proposal is None
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "unclassified"},
])
with _quiet_semantic():
assert (await propose_for_repo(owner, pid, REPO, defs))["proposed"] == 1
rows, _ = await list_project_shapes(owner, pid, proposal="canon")
assert [r.symbol for r in rows] == ["make_app"]
@pytest.mark.integration
async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
"""Shapes with no canon hit that repeat — identical bodies in two files,
the same name in three — carry a derive proposal, and the readout ranks
the families. A canon proposal keeps a row out of any derive group."""
from scribe.services.shape_ledger import (
apply_derive_groups, live_rows, propose_for_repo, proposal_summary,
)
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
defs = _defs(
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
("a/two.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
("b/x.css", "css", "card", ".card {", ".card { padding: 1px }"),
("b/y.css", "css", "card", ".card {", ".card { padding: 2px }"),
("b/z.css", "css", "card", ".card {", ".card { padding: 3px }"),
("c/only.py", "sym", "alone", "def alone():", "def alone():\n return 0"),
("c/use.py", "sym", "boot", "def boot():", "def boot():\n return factory()"),
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="main")
with _quiet_semantic():
await propose_for_repo(owner, pid, REPO, defs)
assert await apply_derive_groups(pid) == 5
rows, total = await list_project_shapes(owner, pid, proposal="derive")
assert total == 5
groups = {(r.path, r.symbol): r.proposal for r in rows}
assert groups[("a/one.py", "slug")]["group"] == groups[("a/two.py", "slug")]["group"]
assert groups[("a/one.py", "slug")]["group"].startswith("dup:")
assert groups[("b/x.css", "card")] == {"basis": "derive", "score": 3.0, "group": "name:css:card"}
rows, _ = await list_project_shapes(owner, pid, proposal="any")
assert {r.symbol for r in rows} == {"slug", "card", "boot"} # boot: reference proposal
summary = proposal_summary(await live_rows(pid))
assert summary["proposed"] == 1
# #2872: the body-identical group (a real copy) outranks the bigger name
# group (usually convention), even at size 2 vs 3.
order = [g["group"] for g in summary["derive_groups"]]
assert order[0].startswith("dup:") and order[1] == "name:css:card"
assert summary["derive_groups"][0]["files"] == 2
assert summary["derive_groups"][0]["label"] == "slug (identical body)"
assert summary["derive_groups"][1]["label"] == ".card"
assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3
# One of the css copies gets judged → the group shrinks on the next pass
# but stays a family: a class in two files is already a recipe living in
# two places (css name floor 2, note 2917). Judge the second and it's gone.
await classify_shapes(owner, pid, [
{"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
])
await apply_derive_groups(pid)
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
assert {r.symbol for r in rows} == {"slug", "card"}
assert {r.path for r in rows if r.symbol == "card"} == {"b/x.css", "b/y.css"}
await classify_shapes(owner, pid, [
{"path": "b/y.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
])
await apply_derive_groups(pid)
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
assert {r.symbol for r in rows} == {"slug"} # 1 file < the css name floor
@pytest.mark.integration
async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded):
"""#2900: against real rows — a name in a family → the family (other
files, count; for CSS a NAME family, never a body one — note 2917); a
name whose canonical row lives elsewhere → that canon; a judged row at
the path, the canon's own file, or an unknown name → silence."""
from scribe.services.shape_ledger import apply_derive_groups, write_time_derive
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
defs = _defs(
("v/A.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
("v/B.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
("v/C.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
("src/factory.py", "sym", "factory", "def factory():", "def factory():\n return 1"),
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="m1")
await classify_shapes(owner, pid, [
{"path": "src/factory.py", "symbol": "factory", "status": "canonical", "snippet_id": sid},
])
assert await apply_derive_groups(pid) >= 3
# A 4th copy about to be written → the family, naming the other files.
out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")])
assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css"
fam = out[0]["family"]
# Three identical bodies, and still a NAME family: CSS never groups by body.
assert fam["identical"] is False and fam["label"] == ".log-empty"
assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3
assert out[0]["key"] == fam["group"] == "name:css:log-empty"
# Editing one existing member still names the OTHER members.
out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")])
assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3
# The canon's name elsewhere → the canon; in the canon's own file → silence.
out = await write_time_derive(pid, "src/other.py", [("sym", "factory")])
assert out == [{"symbol": "factory", "kind": "sym", "key": f"canon:{sid}",
"canon": {"snippet_id": sid, "path": "src/factory.py", "label": "factory"}}]
assert await write_time_derive(pid, "src/factory.py", [("sym", "factory")]) == []
# A judged row at the path is not re-litigated.
await classify_shapes(owner, pid, [
{"path": "v/B.vue", "symbol": "log-empty", "status": "exempt", "reason": "print sheet"},
])
assert await write_time_derive(pid, "v/B.vue", [("css", "log-empty")]) == []
@pytest.mark.integration
async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(seeded):
"""#2899: the first sync seeds one `slug`; a later sync adds an identical
copy. Against the stamp between them, derive_new counts ONLY the
newcomer — the drift since the last refresh, not the whole family."""
from datetime import datetime, timezone
from scribe.services.shape_ledger import (
apply_derive_groups, derive_new_summary, live_rows,
)
owner, pid = seeded["owner"], seeded["pid"]
first = _defs(
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
)
await sync_repo_shapes(pid, REPO, first, seen_marker="m1")
stamp = datetime.now(timezone.utc)
second = _defs(
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
("a/two.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
)
await sync_repo_shapes(pid, REPO, second, seen_marker="m2")
assert await apply_derive_groups(pid) == 2
rows = await live_rows(pid)
out = derive_new_summary(rows, since=stamp)
assert out["count"] == 1
assert out["examples"][0]["path"] == "a/two.py"
assert out["examples"][0]["label"] == "slug"
assert out["examples"][0]["group"].startswith("dup:")
assert derive_new_summary(rows, since=None)["count"] == 0
@pytest.mark.integration
async def test_transition_and_prefix_references_clear_the_unused_css_flag(seeded):
"""#2970 end to end: the two class forms a template never spells out —
a transition `name=` and a concatenated name — travel from the real
extractor through resolution into the flag, so rules they reach stop
being reported as unused. Only a rule nothing can reach stays listed."""
from scribe.services.coverage import class_references
from scribe.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows
pid, owner = seeded["pid"], seeded["owner"]
defs = _defs(
("assets/anim.css", "css", "toast-enter-active",
".toast-enter-active {", ".toast-enter-active { opacity: 0 }"),
("assets/anim.css", "css", "toast-leave-to",
".toast-leave-to {", ".toast-leave-to { opacity: 0 }"),
("assets/state.css", "css", "status-done",
".status-done {", ".status-done { color: green }"),
("assets/state.css", "css", "status-todo",
".status-todo {", ".status-todo { color: grey }"),
("assets/state.css", "css", "really-dead",
".really-dead {", ".really-dead { color: red }"),
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="t1")
refs = {
# Vue applies the toast-* classes itself; the markup names none of them.
"v/Toast.vue": class_references(
"v/Toast.vue", '<transition-group name="toast"><li /></transition-group>'
),
# The board builds its status class; which one is unknowable.
"v/Board.vue": class_references("v/Board.vue", '<b :class="`status-${s}`" />'),
}
assert "toast-enter-active" in refs["v/Toast.vue"]
assert refs["v/Board.vue"] == {"status-*": 1}
await sync_repo_consumers(pid, REPO, refs)
live = [r for r in await live_rows(pid) if r.kind == "css"]
used = await used_by_map(live)
by = {(r.path, r.symbol): used[r.id]["count"] for r in live}
assert by[("assets/anim.css", "toast-enter-active")] == 1
assert by[("assets/anim.css", "toast-leave-to")] == 1
# the prefix credits BOTH candidates — the template does not say which
assert by[("assets/state.css", "status-done")] == 1
assert by[("assets/state.css", "status-todo")] == 1
assert by[("assets/state.css", "really-dead")] == 0
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
assert n == 1 and [(r.path, r.symbol) for r in unused] == [
("assets/state.css", "really-dead")
]
@pytest.mark.integration
async def test_consumer_map_syncs_edges_from_template_references(seeded):
"""Milestone 302: the consumer edges follow the archive — own-file
resolution for a scoped class, fan-out to the shared sheet for a class a
template does not define, counts refreshed and stale edges removed on
the next sync, and a vanished row's edges gone with it."""
from scribe.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers
pid = seeded["pid"]
defs = _defs(
("v/A.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: red }"),
("v/B.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: blue }"),
("assets/components.css", "css", "btn-primary", ".btn-primary {", ".btn-primary { x: 1 }"),
("assets/orphan.css", "css", "orphan", ".orphan {", ".orphan { y: 2 }"), # no template names it
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="m1")
refs = {
"v/A.vue": {"error-msg": 2, "btn-primary": 1},
"v/B.vue": {"error-msg": 1},
"v/C.vue": {"error-msg": 1, "btn-primary": 4},
}
# A and B consume their OWN error-msg; C defines none, so its use fans
# out to both rows; btn-primary resolves to the shared sheet from A and C.
assert await sync_repo_consumers(pid, REPO, refs) == 6
rows = {(r.path, r.symbol): r.id for r in await live_rows(pid) if r.kind == "css"}
edges = await consumers_of(rows.values())
view = {(p, s): [(e.path, e.count) for e in edges.get(i, [])] for (p, s), i in rows.items()}
assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2), ("v/C.vue", 1)]
assert view[("v/B.vue", "error-msg")] == [("v/B.vue", 1), ("v/C.vue", 1)]
assert view[("assets/components.css", "btn-primary")] == [("v/A.vue", 1), ("v/C.vue", 4)]
assert view[("assets/orphan.css", "orphan")] == []
# The next tree: C stops using error-msg, A uses btn-primary twice now.
refs2 = {"v/A.vue": {"error-msg": 2, "btn-primary": 2}, "v/B.vue": {"error-msg": 1}}
assert await sync_repo_consumers(pid, REPO, refs2) == 3
edges = await consumers_of(rows.values())
assert [(e.path, e.count) for e in edges[rows[("v/B.vue", "error-msg")]]] == [("v/B.vue", 1)]
assert [(e.path, e.count) for e in edges[rows[("assets/components.css", "btn-primary")]]] == [("v/A.vue", 2)]
# The readout side: used_by per css row, the unused-css flag, and the
# family's consumers on the write-path check.
from scribe.services.shape_ledger import (
apply_derive_groups, used_by_map, write_time_derive,
)
owner = seeded["owner"]
live = [r for r in await live_rows(pid) if r.kind == "css"]
used = await used_by_map(live)
assert used[rows[("v/B.vue", "error-msg")]] == {"count": 1, "paths": ["v/B.vue"]}
assert used[rows[("assets/orphan.css", "orphan")]] == {"count": 0, "paths": []}
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
assert n == 1 and [(r.path, r.symbol) for r in unused] == [("assets/orphan.css", "orphan")]
await apply_derive_groups(pid)
out = await write_time_derive(pid, "v/New.vue", [("css", "error-msg")])
assert out and out[0]["family"]["consumers"] == {"count": 2, "paths": ["v/A.vue", "v/B.vue"]}
# B's rule vanishes from the tree → its edges go with the pass.
await sync_repo_shapes(pid, REPO, [d for d in defs if d[0] != "v/B.vue"], seen_marker="m2")
await sync_repo_consumers(pid, REPO, refs2)
edges = await consumers_of(rows.values())
assert rows[("v/B.vue", "error-msg")] not in edges
# --- #2793: the divergence readout against real rows -------------------------
@pytest.mark.integration
async def test_a_second_confirm_dialog_is_detected_and_named(seeded):
"""The milestone's acceptance case. A directory where one canon dominates
the judged siblings (a confirm helper with four instance call sites);
after a previous refresh, a new shape lands there that the proposer does
not match to the canon — it is flagged `diverges_from` the canon, the
readout names it, and the in-band check names it at write time. A
judgment clears the flag; a shape proposed AS the canon is not flagged."""
from datetime import datetime, timedelta, timezone
from scribe.services.shape_ledger import (
divergence_summary, flag_divergence, live_rows, propose_for_repo,
write_time_divergence,
)
from scribe.services import snippets as snippets_svc
owner, pid = seeded["owner"], seeded["pid"]
# The confirm helper is TS canon: since #2871 a sym basis only proposes
# within the shape's language family, so the fixture's Python snippet
# says nothing about these Vue bodies — the canon must be one of theirs.
canon = await snippets_svc.create_snippet(
owner, name="cls_confirm_factory",
code="export async function factory(): Promise<boolean> {\n return true;\n}\n",
language="typescript", repo="Widget",
path="frontend/src/composables/useConfirm.ts", symbol="factory",
project_id=pid,
)
sid = int(canon.id)
comp = "frontend/src/components"
base = _defs(
*[(f"{comp}/{n}.vue", "sym", f"on{n}", f"async function on{n}() {{",
f"async function on{n}() {{\n const ok = await factory();\n if (!ok) return;\n}}")
for n in ("Trash", "Delete", "Remove", "Restore")],
)
await sync_repo_shapes(pid, REPO, base, seen_marker="aaa111")
await classify_shapes(owner, pid, [
{"path": f"{comp}/{n}.vue", "symbol": f"on{n}", "status": "instance", "snippet_id": sid}
for n in ("Trash", "Delete", "Remove", "Restore")
], via="audit")
previous = datetime.now(timezone.utc)
# Button B: a hand-rolled confirm that never touches the canon, plus a
# proper new instance (references the canon → the proposer claims it).
later = base + _defs(
(f"{comp}/Danger.vue", "sym", "confirmDanger", "function confirmDanger() {",
"function confirmDanger() {\n return window.confirm('Really?');\n}"),
(f"{comp}/Proper.vue", "sym", "onPurge", "async function onPurge() {",
"async function onPurge() {\n const ok = await factory();\n if (!ok) return;\n}"),
)
await sync_repo_shapes(pid, REPO, later, seen_marker="bbb222")
with _quiet_semantic():
await propose_for_repo(owner, pid, REPO, later)
assert await flag_divergence(pid, since=None) == 0 # a first seed flags nothing
assert await flag_divergence(pid, since=previous - timedelta(seconds=1)) == 1
rows, total = await list_project_shapes(owner, pid, flag="divergence")
assert total == 1
assert rows[0].symbol == "confirmDanger" and rows[0].diverges_from == sid
summary = divergence_summary(await live_rows(pid))
assert summary["divergent"] == 1
assert summary["divergence"][0]["symbol"] == "confirmDanger"
assert summary["divergence"][0]["canon_snippet_id"] == sid
# In-band: the hook names the shape at write time → the check names the canon.
named = await write_time_divergence(
pid, f"{comp}/Danger.vue", [("sym", "confirmDanger")], stamped=[]
)
assert named == [{"symbol": "confirmDanger", "kind": "sym", "canon_snippet_id": sid,
"instances": 4, "judged": 4}]
# ...but an already-judged shape, or one just stamped as the canon's
# instance, is not re-litigated.
assert await write_time_divergence(pid, f"{comp}/Trash.vue", [("sym", "onTrash")], stamped=[]) == []
assert await write_time_divergence(
pid, f"{comp}/New.vue", [("sym", "onNew")],
stamped=[{"symbol": "onNew", "kind": "sym", "snippet_id": sid}],
) == []
# A directory with no dominant canon is silent.
assert await write_time_divergence(pid, "src/other.py", [("sym", "thing")], stamped=[]) == []
# The judgment answers the question and clears the flag.
await classify_shapes(owner, pid, [
{"path": f"{comp}/Danger.vue", "symbol": "confirmDanger", "status": "variant",
"snippet_id": sid, "reason": "native confirm is fine in the dev-only panel"},
])
rows, total = await list_project_shapes(owner, pid, flag="divergence")
assert total == 0
@pytest.mark.integration
async def test_a_semantic_miss_does_not_silence_the_prompt(seeded):
"""#4208, reversed on measurement: a semantic miss is not evidence.
THE FIXTURE IS THE ACCEPTANCE CASE ABOVE, DELIBERATELY. A hand-rolled
`confirmDanger` beside an async confirm canon is structurally identical to
a registry helper beside an async service canon, and the arm's miss was
meant to tell them apart. Measured live, true instances of a canon rarely
clear the arm's floor either, so a miss fires on both — silencing on
it silenced #2793's own case. Here the arm has read the body, found
nothing, and the prompt is still RAISED.
Asserted on the row, not a flag count: `since` sits a second early to
absorb clock skew, which also admits other fixture rows as "new".
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from scribe.services import shape_ledger
from scribe.services import snippets as snippets_svc
from scribe.services.shape_ledger import flag_divergence, live_rows, propose_for_repo
owner, pid = seeded["owner"], seeded["pid"]
canon = await snippets_svc.create_snippet(
owner, name="cls_confirm_factory_meaning",
code="export async function factory(): Promise<boolean> {\n return true;\n}\n",
language="typescript", repo="Widget",
path="frontend/src/composables/useConfirm.ts", symbol="factory",
project_id=pid,
)
sid = int(canon.id)
comp = "frontend/src/components"
base = _defs(
*[(f"{comp}/{n}.vue", "sym", f"on{n}", f"async function on{n}() {{",
f"async function on{n}() {{\n const ok = await factory();\n if (!ok) return;\n}}")
for n in ("Trash", "Delete", "Remove", "Restore")],
)
await sync_repo_shapes(pid, REPO, base, seen_marker="aaa111")
await classify_shapes(owner, pid, [
{"path": f"{comp}/{n}.vue", "symbol": f"on{n}", "status": "instance", "snippet_id": sid}
for n in ("Trash", "Delete", "Remove", "Restore")
], via="audit")
previous = datetime.now(timezone.utc)
later = base + _defs(
(f"{comp}/Danger.vue", "sym", "confirmDanger", "function confirmDanger() {",
"function confirmDanger() {\n return window.confirm('Really?');\n}"),
)
await sync_repo_shapes(pid, REPO, later, seen_marker="bbb222")
miss = AsyncMock(return_value=None)
with patch.object(shape_ledger, "_semantic_canon", miss):
await propose_for_repo(owner, pid, REPO, later)
assert miss.await_count >= 1, "the arm must actually have read a body"
await flag_divergence(pid, since=previous - timedelta(seconds=1))
danger = next(r for r in await live_rows(pid) if r.symbol == "confirmDanger")
assert danger.proposed_snippet_id is None
assert danger.diverges_from == sid, "a miss at the arm's floor is 'cannot tell'"
async def _two_generations(seeded, tag: str):
"""A canon-dense directory, an OLD unjudged helper, then one NEW shape.
The same density as the acceptance fixture above, with an extra unjudged
row from the first sync so there is an old row competing with the new one
for the capped semantic pass.
"""
from datetime import datetime, timezone
from scribe.services import snippets as snippets_svc
owner, pid = seeded["owner"], seeded["pid"]
canon = await snippets_svc.create_snippet(
owner, name=f"cls_confirm_factory_{tag}",
code="export async function factory(): Promise<boolean> {\n return true;\n}\n",
language="typescript", repo="Widget",
path="frontend/src/composables/useConfirm.ts", symbol="factory",
project_id=pid,
)
sid = int(canon.id)
comp = "frontend/src/components"
base = _defs(
*[(f"{comp}/{n}.vue", "sym", f"on{n}", f"async function on{n}() {{",
f"async function on{n}() {{\n const ok = await factory();\n if (!ok) return;\n}}")
for n in ("Trash", "Delete", "Remove", "Restore")],
(f"{comp}/Old.vue", "sym", "oldHelper", "function oldHelper() {",
"function oldHelper() {\n return document.title.length > 0;\n}"),
)
await sync_repo_shapes(pid, REPO, base, seen_marker=f"{tag}-1")
await classify_shapes(owner, pid, [
{"path": f"{comp}/{n}.vue", "symbol": f"on{n}", "status": "instance", "snippet_id": sid}
for n in ("Trash", "Delete", "Remove", "Restore")
], via="audit")
previous = datetime.now(timezone.utc)
later = base + _defs(
(f"{comp}/Danger.vue", "sym", "confirmDanger", "function confirmDanger() {",
"function confirmDanger() {\n return window.confirm('Really?');\n}"),
)
await sync_repo_shapes(pid, REPO, later, seen_marker=f"{tag}-2")
return owner, pid, later, previous
@pytest.mark.integration
async def test_the_capped_semantic_pass_reaches_the_new_shape_first(seeded):
"""Measured live: after the #4208 version bump, 1,533 rows competed for 150
semantic checks in row order and the six new shapes — the only ones the
divergence check acts on — were all left unread and flagged. With room for
ONE check, the new shape must be the one that gets it."""
from unittest.mock import AsyncMock, patch
from scribe.services import shape_ledger
from scribe.services.shape_ledger import live_rows, propose_for_repo
owner, pid, later, _previous = await _two_generations(seeded, "cap")
with patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None)):
stats = await propose_for_repo(owner, pid, REPO, later, semantic_cap=1)
assert stats["semantic_checked"] == 1
rows = {r.symbol: r for r in await live_rows(pid)}
assert rows["confirmDanger"].proposed_sha != "", "the new shape got the one check"
# The old row is left for the next refresh, unexamined — never stamped.
assert rows["oldHelper"].proposed_sha == ""
@pytest.mark.integration
async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(seeded):
from scribe.services.shape_ledger import shape_history
owner, other, pid, sid = (
seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
)
v1 = _defs(("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n return factory()"))
await sync_repo_shapes(pid, REPO, v1, seen_marker="c1")
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance", "snippet_id": sid},
])
# The body moves under the judgment → drifted + recheck; re-judging clears it.
v2 = _defs(("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n return factory(debug=True)"))
await sync_repo_shapes(pid, REPO, v2, seen_marker="c2")
rows, total = await list_project_shapes(owner, pid, flag="recheck")
assert total == 1 and rows[0].symbol == "make_app" and rows[0].status == "instance"
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "variant", "snippet_id": sid,
"reason": "debug flag is deliberate here"},
])
rows, total = await list_project_shapes(owner, pid, flag="recheck")
assert total == 0
# Then it vanishes from the tree.
await sync_repo_shapes(pid, REPO, [], seen_marker="c3")
history = await shape_history(owner, pid, "src/app.py", symbol="make_app")
shape = history["shapes"][0]
assert shape["status"] == "variant" and shape["vanished_at"] is not None
# The seeded fixture synced this row first (marker "main"); v1/v2 are
# later sightings — first_seen keeps the first.
assert shape["first_seen_commit"] == "main" and shape["last_seen_commit"] == "c2"
timeline = [(e["event"], e["status"], e["snippet_id"], e["commit"]) for e in history["events"]]
assert timeline == [
("classified", "instance", sid, "c1"),
("drifted", "instance", sid, "c2"),
("classified", "variant", sid, "c2"),
("vanished", "variant", sid, "c2"),
]
assert history["events"][2]["reason"] == "debug flag is deliberate here"
assert history["events"][0]["classified_by"] == "agent"
# Directory-wide read works (the empty sync also vanished the seeded
# Config and helper rows under src/ — two more events); an outsider
# reads nothing.
assert len((await shape_history(owner, pid, "src"))["events"]) == 6
assert await shape_history(other, pid, "src/app.py") == {}