CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 33s
CI & Build / Build & push image (push) Skipped
The UI Refresh button must not be the only seed path (operator directive, hit live: the P7 backfill stalled waiting for a click). Three parts: - enter_project fire-and-forgets refresh_if_stale on the project OWNER — absent or day-old readouts recompute in the background (same spawn the webhook path uses), the enter stays fast, forge-less owners exit quietly (rule #115 baseline), and an in-flight guard keeps concurrent enters from fetching the same tarball N times. - refresh_pattern_coverage(project_id): the synchronous agent-facing form — write-gated, owner-keyring resolution, and ValueError messages that name the fix (add a connection / bind_repo) instead of measuring nothing silently. - plugin 0.1.33 ships the shape-accounting skill: the five statuses, the seed/todo/judge loop, and the derive-first rule, triggered by the coverage line or any proved code-to-canon relationship. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
85 lines
3.5 KiB
Python
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", "refresh_pattern_coverage"):
|
|
assert mcp._tool_manager.get_tool(name) is not None
|