"""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. """ import pytest 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", "scoped", "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", "refresh_pattern_coverage"): assert mcp._tool_manager.get_tool(name) is not None # --- step 5: the write-path feed's evidence tests (pure) --------------------- def test_symbol_reference_is_word_bounded_and_kind_aware(): from scribe.services.shape_ledger import references_symbol as ref code = "const ok = await confirmed({ title: 'x' });\nif (!ok) return;" assert ref(code, "confirmed", "sym") assert not ref(code, "confirm", "sym") # prefix never claims the call assert not ref("", "confirmed", "sym") assert not ref(code, "", "sym") # CSS: the class as a selector or inside a class attribute; dashes are part # of the name, so `btn` must not claim `btn-primary`. html = '' assert ref(html, ".btn-primary", "css") assert ref(html, "btn-primary", "css") assert ref(".btn-primary { color: red }", ".btn-primary", "css") assert not ref('