feat(ledger): mechanical proposer — every refresh proposes instances against canon and groups derive-first candidates; agents confirm in batches (#2792, milestone 294 step 6)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 48s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 48s
Shapes now carry a content fingerprint (signature + whitespace/comment- insensitive body_sha; migration 0080) and the proposer runs inside the coverage refresh, the one moment bodies exist: symbol elsewhere → textual containment → body references the canon → signature resemblance → semantic (capped per refresh, unreached rows stay unexamined for the next). A hit is a proposal on the row (proposed_snippet_id/basis/score), never a classification; rows with no canon hit group by the derive-first rule (identical body in ≥2 places, same name in ≥3 files) as proposal_basis= derive + a group key. list_shapes(proposal=any|canon|derive|<basis>) is the queue; confirm_shape_proposals(project_id, snippet_id|path|basis) confirms in batches as agent instances; any classify_shapes/hook stamp retires the proposal. Readout carries proposed + derive_groups (line, payload, card). Plugin 0.1.35 (skill: the machine proposes, judgment classifies). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ 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
|
||||
|
||||
@@ -135,3 +137,142 @@ def test_hook_is_a_server_internal_classifier():
|
||||
|
||||
assert "hook" in SHAPE_CLASSIFIERS
|
||||
assert "hook" not in _CALLER_VIAS
|
||||
|
||||
|
||||
# --- step 6: the mechanical proposer (pure) ---------------------------------
|
||||
|
||||
|
||||
def test_proposal_columns_and_vocabulary_are_pinned():
|
||||
"""Fingerprints + the proposer's standing suggestion live on the row; the
|
||||
basis vocabulary is fixed, with `derive` the odd one out (a group, not a
|
||||
snippet)."""
|
||||
from scribe.models.code_shape import PROPOSAL_BASES
|
||||
|
||||
cols = CodeShape.__table__.c
|
||||
for name in ("signature", "body_sha", "proposed_snippet_id", "proposal_basis",
|
||||
"proposal_score", "proposal_group", "proposed_at", "proposed_sha"):
|
||||
assert name in cols, name
|
||||
fk = next(iter(cols.proposed_snippet_id.foreign_keys))
|
||||
assert fk.ondelete == "SET NULL" and fk.column.table.name == "notes"
|
||||
assert "ix_code_shapes_proposed" in {ix.name for ix in CodeShape.__table__.indexes}
|
||||
assert PROPOSAL_BASES == ("symbol", "text", "reference", "signature", "semantic", "derive")
|
||||
|
||||
|
||||
def test_row_proposal_property_is_one_object_or_none():
|
||||
row = CodeShape(project_id=1, repo_key="r", path="a.py", symbol="f", kind="sym")
|
||||
assert row.proposal is None
|
||||
row.proposed_snippet_id, row.proposal_basis, row.proposal_score = 9, "symbol", 1.0
|
||||
assert row.proposal == {"basis": "symbol", "score": 1.0, "snippet_id": 9}
|
||||
row.proposed_snippet_id = None
|
||||
row.proposal_basis, row.proposal_group, row.proposal_score = "derive", "dup:abc", 3.0
|
||||
assert row.proposal == {"basis": "derive", "score": 3.0, "group": "dup:abc"}
|
||||
|
||||
|
||||
def test_signature_similarity_blanks_the_names():
|
||||
from scribe.services.shape_ledger import signature_similarity as sim
|
||||
|
||||
a = "def move_event(project_id: int, event_id: int, after_id: int | None):"
|
||||
b = "def move_beat(project_id: int, beat_id: int, after_id: int | None):"
|
||||
assert sim(a, "move_event", b, "move_beat") > 0.85
|
||||
assert sim(a, "move_event", "def export_pdf(manuscript, design, fonts):", "export_pdf") < 0.6
|
||||
assert sim("", "x", b, "move_beat") == 0.0
|
||||
# Trivial signatures resemble everything and mean nothing — floored out.
|
||||
assert sim("def helper(x):", "helper", "def make_app():", "make_app") == 0.0
|
||||
|
||||
|
||||
def test_text_containment_is_whitespace_insensitive_with_a_floor():
|
||||
from scribe.services.shape_ledger import text_contains
|
||||
|
||||
code = "const ok = await confirmed({ title: 'Delete?', confirmLabel: 'Delete' });\nif (!ok) return;"
|
||||
body = "async function onDelete() {\n const ok = await confirmed({\n title: 'Delete?',\n confirmLabel: 'Delete'\n });\n if (!ok) return;\n}"
|
||||
assert text_contains(body, code)
|
||||
assert text_contains(code, body)
|
||||
assert not text_contains("x = 1", "x = 1") # below the substance floor
|
||||
|
||||
|
||||
def _canon(sid, kind="sym", symbol="", locations=(), signature="", code=""):
|
||||
from scribe.services.shape_ledger import Canon, _norm_text
|
||||
return Canon(sid, kind, symbol, tuple(locations), signature, _norm_text(code))
|
||||
|
||||
|
||||
def test_match_canon_orders_bases_strongest_first_and_respects_kind():
|
||||
from scribe.services.shape_ledger import match_canon
|
||||
|
||||
confirmed = _canon(
|
||||
7, "sym", "confirmed", [("frontend/src/composables/useConfirm.ts", "confirmed")],
|
||||
"export async function confirmed(opts: ConfirmOptions): Promise<boolean> {",
|
||||
"export async function confirmed(opts: ConfirmOptions): Promise<boolean> { /* singleton */ }",
|
||||
)
|
||||
mover = _canon(
|
||||
8, "sym", "move_beat", [("src/forge/plot.py", "move_beat")],
|
||||
"def move_beat(project_id: int, beat_id: int, after_id: int | None) -> None:",
|
||||
)
|
||||
btn = _canon(9, "css", ".btn-primary", [("web/buttons.css", ".btn-primary")],
|
||||
".btn-primary {", ".btn-primary { color: var(--action-primary); padding: 4px 8px; border-radius: 4px; }")
|
||||
canons = [confirmed, mover, btn]
|
||||
|
||||
# symbol: a second `confirmed` defined elsewhere answers to #7 — but the
|
||||
# canon's own location never does (that row is canonical, not a proposal).
|
||||
assert match_canon("sym", "src/other.ts", "confirmed", "function confirmed() {", "", canons) == (7, "symbol", 1.0)
|
||||
assert match_canon("sym", "frontend/src/composables/useConfirm.ts", "confirmed",
|
||||
"export async function confirmed(", "", canons) is None
|
||||
# reference: a call site of the canon.
|
||||
body = "async function onTrash() {\n const ok = await confirmed({ title: 'x' });\n if (!ok) return;\n}"
|
||||
assert match_canon("sym", "c.vue", "onTrash", "async function onTrash() {", body, canons) == (7, "reference", 0.9)
|
||||
# signature: the family shape, names blanked.
|
||||
hit = match_canon("sym", "src/forge/timeline.py", "move_event",
|
||||
"def move_event(project_id: int, event_id: int, after_id: int | None) -> None:",
|
||||
" pass", canons)
|
||||
assert hit and hit[0] == 8 and hit[1] == "signature" and hit[2] >= 0.8
|
||||
# text: the canon's code contains the shape's body (a css copy), kind-matched —
|
||||
# the same text as a `sym` shape matches no css canon.
|
||||
css_body = ".btn-primary { color: var(--action-primary); padding: 4px 8px; border-radius: 4px; }"
|
||||
assert match_canon("css", "web/other.css", "btn-big", ".btn-big {", css_body, canons) == (9, "text", 0.95)
|
||||
assert match_canon("sym", "web/other.css", "btn-big", ".btn-big {", css_body, canons) is None
|
||||
# nothing in play
|
||||
assert match_canon("sym", "x.py", "unrelated", "def unrelated(a, b, c, d, e):", "return 1", canons) is None
|
||||
|
||||
|
||||
def test_match_canon_symbol_beats_everything_including_css_copies():
|
||||
"""The previous test's css `btn-primary`-elsewhere case, stated plainly:
|
||||
a second definition of the canon's own name is the symbol basis."""
|
||||
from scribe.services.shape_ledger import match_canon
|
||||
btn = _canon(9, "css", ".btn-primary", [("web/buttons.css", ".btn-primary")], ".btn-primary {", ".btn-primary { color: red; padding: 4px 8px; border-radius: 4px; }")
|
||||
assert match_canon("css", "web/other.css", "btn-primary", ".btn-primary {", ".btn-primary { color: blue }", [btn]) == (9, "symbol", 1.0)
|
||||
|
||||
|
||||
def test_derive_groups_copy_before_name_with_floors():
|
||||
from scribe.services.shape_ledger import derive_groups
|
||||
|
||||
rows = [
|
||||
("a.py", "sym", "helper", "sha1"), ("b.py", "sym", "helper", "sha1"), # identical copies
|
||||
("c.py", "sym", "helper", "sha9"), # same name, 3rd file
|
||||
("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"),
|
||||
("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group
|
||||
("i.py", "sym", "one", "s6"),
|
||||
]
|
||||
g = derive_groups(rows)
|
||||
assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")]
|
||||
assert g[("c.py", "sym", "helper")] == "name:sym:helper"
|
||||
assert g[("d.css", "css", "btn")] == "name:css:btn"
|
||||
assert ("g.py", "sym", "main") not in g
|
||||
assert ("i.py", "sym", "one") not in g
|
||||
|
||||
|
||||
def test_confirm_requires_a_named_scope():
|
||||
import asyncio
|
||||
|
||||
from scribe.services.shape_ledger import confirm_proposals
|
||||
|
||||
with pytest.raises(ValueError) as err:
|
||||
asyncio.run(confirm_proposals(1, 2))
|
||||
assert "name what you reviewed" in str(err.value)
|
||||
|
||||
|
||||
def test_proposer_tools_are_mounted():
|
||||
from scribe.mcp.server import build_mcp_server
|
||||
|
||||
mcp = build_mcp_server()
|
||||
assert mcp._tool_manager.get_tool("confirm_shape_proposals") is not None
|
||||
tool = mcp._tool_manager.get_tool("list_shapes")
|
||||
assert "proposal" in tool.parameters.get("properties", {})
|
||||
|
||||
Reference in New Issue
Block a user