Files
FabledScribe/tests/test_shape_ledger.py
T
bvandeusenandClaude Fable 5 942edd1eb5
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
feat(ledger): classify_shapes + list_shapes MCP tools; get_snippet carries the consumer map (#2789, milestone 294 step 3)
The judgment write path. classify_shapes applies a batch of classifications
to a project's live ledger rows — all-or-nothing (#2709's lesson: the whole
batch is validated, write-ACL'd, and every snippet target proven readable
before any row is touched); rows match by exact (path, symbol), kind narrows,
and shapes no live row matches come back as 'unmatched' rather than errors.
variant/exempt REQUIRE the reason — the why is the record (note 2786) — and
'unclassified' deliberately withdraws a judgment back to the todo. The 'via'
channel is caller-restricted to agent|audit|import; hook and mechanical stay
server-internal so a caller can't launder judgment as machinery.

list_shapes is the todo query (status=unclassified) with composable filters:
path is exact-or-under like recorded locations, snippet_id reads a consumer
map, include_vanished reads history; paged with the true total.

get_snippet now attaches  and  — the structured consumer
map, filtered to projects the CALLER can read so a shared snippet never side-
channels another project's file layout; attached only when non-empty (#2483).

Integration tests pin the batch atomicity, ACL gates, filter composition, the
consumer map on the MCP pull, and the SET NULL companion: a judgment whose
snippet was purged rejoins the todo on the next sync.

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

85 lines
3.5 KiB
Python

"""The shape ledger's schema contract (#2787, milestone 294, note 2786).
Step 1 pins the model: identity, the classification vocabulary, and the
token-free serialisation. The sync pass (step 2) and the classification
surface (step 3) grow their tests here; DB-backed behavior lands in the
integration lane once there is behavior to exercise.
"""
from scribe.models import Base
from scribe.models.code_shape import SHAPE_CLASSIFIERS, SHAPE_STATUSES, CodeShape
def test_identity_is_project_repo_path_symbol_kind():
"""Kind is part of identity on purpose: one file can define `.foo` (css)
and `foo` (sym) as distinct shapes — the extractor emits both."""
table = Base.metadata.tables["code_shapes"]
unique = next(
c for c in table.constraints
if getattr(c, "name", "") == "uq_code_shapes_identity"
)
assert [c.name for c in unique.columns] == [
"project_id", "repo_key", "path", "symbol", "kind",
]
def test_the_todo_state_is_the_default():
"""A shape nobody has judged yet must read `unclassified` — the ledger's
todo list — never silently look classified."""
assert CodeShape.__table__.c.status.default.arg == "unclassified"
assert "unclassified" in SHAPE_STATUSES
assert set(SHAPE_STATUSES) == {
"canonical", "instance", "variant", "exempt", "unclassified",
}
assert set(SHAPE_CLASSIFIERS) == {
"agent", "audit", "hook", "mechanical", "import",
}
def test_snippet_reference_survives_snippet_deletion_as_null():
"""SET NULL, not CASCADE: a deleted snippet must not silently erase the
accounting rows that pointed at it — the sync pass re-files them as
unclassified so they rejoin the todo."""
fk = next(iter(CodeShape.__table__.c.snippet_id.foreign_keys))
assert fk.ondelete == "SET NULL"
assert fk.column.table.name == "notes"
def test_status_queries_have_an_index():
"""list_shapes(status=unclassified) is THE todo query (step 3) — it must
not degrade into a project-wide scan as ledgers reach thousands of rows."""
names = {ix.name for ix in CodeShape.__table__.indexes}
assert "ix_code_shapes_project_status" in names
assert "ix_code_shapes_snippet" in names
# --- step 3: the classification batch validator (pure, checked before ACL) ---
def test_batch_validation_names_the_failing_item():
from scribe.services.shape_ledger import validate_classifications as v
ok = {"path": "src/a.py", "symbol": "f", "status": "exempt", "reason": "one-off"}
assert v([ok]) is None
assert "empty" in v([])
assert "classifications[1]" in v([ok, {"symbol": "f", "status": "exempt"}])
assert "unknown status" in v([{**ok, "status": "covered"}])
# A judgment that references canon must name the canon...
assert "needs snippet_id" in v(
[{"path": "a", "symbol": "f", "status": "instance"}]
)
# ...and a departure/exemption must carry its why — the why IS the record.
assert "needs a reason" in v(
[{"path": "a", "symbol": "f", "status": "variant", "snippet_id": 3}]
)
assert "needs a reason" in v([{"path": "a", "symbol": "f", "status": "exempt"}])
# Withdrawing a judgment needs neither target nor reason.
assert v([{"path": "a", "symbol": "f", "status": "unclassified"}]) is None
def test_classify_and_list_are_mounted_as_mcp_tools():
from scribe.mcp.server import build_mcp_server
mcp = build_mcp_server()
for name in ("classify_shapes", "list_shapes"):
assert mcp._tool_manager.get_tool(name) is not None