feat(ledger): divergence readout — button B where button A is canon, shape history, and judged-shape recheck (#2793, milestone 294 step 7)
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Every judgment now goes through one helper that remembers the fingerprint judged (classified_sha) and writes a code_shape_events row; the sync writes vanished / reappeared / drifted events and flags recheck_at when a body moves under an instance/variant. The refresh flags diverges_from on shapes new since the previous computation that sit where one canon dominates the judged siblings of their directory+kind and were not proposed as that canon (a first seed flags nothing); the write-path hint asks the same question in-band for the shapes the hook names. list_shapes(flag=divergence|recheck), shape_history(project_id, path, symbol) (read-only), coverage line/payload/ card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -493,3 +493,130 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
|
||||
await apply_derive_groups(pid)
|
||||
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
|
||||
assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor
|
||||
|
||||
|
||||
# --- #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,
|
||||
)
|
||||
|
||||
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
|
||||
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_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") == {}
|
||||
|
||||
@@ -507,3 +507,19 @@ def test_coverage_line_names_the_proposers_standing():
|
||||
assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line
|
||||
line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]})
|
||||
assert "(1 derive group)" in line
|
||||
|
||||
|
||||
def test_coverage_line_names_divergence_and_recheck():
|
||||
from scribe.services.coverage import coverage_line
|
||||
|
||||
base = {
|
||||
"total": 100, "accounted": 40, "unclassified": 60,
|
||||
"counts": {"canonical": 10, "instance": 30, "variant": 0, "exempt": 0},
|
||||
"computed_at": "2026-08-21T00:00:00+00:00",
|
||||
"largest_gaps": [{"dir": "src", "unclassified": 60, "total": 60}],
|
||||
}
|
||||
line = coverage_line({**base, "divergent": 2, "recheck": 1, "proposed": 5})
|
||||
assert "; 60 unclassified (5 proposed, 2 DIVERGENT), largest: src" in line
|
||||
assert line.endswith("; 1 judged shape changed since judged — recheck")
|
||||
assert "DIVERGENT" not in coverage_line(base)
|
||||
assert "recheck" not in coverage_line(base)
|
||||
|
||||
@@ -13,11 +13,12 @@ import pytest
|
||||
from scribe.services import backup
|
||||
|
||||
|
||||
def test_backup_version_is_v7():
|
||||
"""v7 added code_shapes (#2787). The bump is the point of the test —
|
||||
a payload section added without moving the version produces backups that
|
||||
are structurally different and indistinguishable by inspection."""
|
||||
assert backup.BACKUP_VERSION == 7
|
||||
def test_backup_version_is_v8():
|
||||
"""v7 added code_shapes (#2787), v8 its history (#2793). The bump is the
|
||||
point of the test — a payload section added without moving the version
|
||||
produces backups that are structurally different and indistinguishable
|
||||
by inspection."""
|
||||
assert backup.BACKUP_VERSION == 8
|
||||
|
||||
|
||||
def test_not_included_lists_the_known_gaps():
|
||||
@@ -114,7 +115,7 @@ async def test_export_full_backup_contains_every_declared_section():
|
||||
"topic_suppressions",
|
||||
"systems", "record_systems", "design_systems",
|
||||
"design_tokens", "note_usage_events", "repo_bindings",
|
||||
"note_supersessions", "code_shapes"):
|
||||
"note_supersessions", "code_shapes", "code_shape_events"):
|
||||
assert key in out, f"missing export section: {key}"
|
||||
assert out[key] == []
|
||||
|
||||
|
||||
@@ -291,3 +291,54 @@ def test_proposer_tools_are_mounted():
|
||||
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", {})
|
||||
|
||||
|
||||
# --- step 7: the divergence readout (pure) ----------------------------------
|
||||
|
||||
|
||||
def _row(path, kind="sym", status="unclassified", snippet_id=None):
|
||||
r = CodeShape(project_id=1, repo_key="r", path=path, symbol=path.rsplit("/", 1)[-1], kind=kind)
|
||||
r.status, r.snippet_id = status, snippet_id
|
||||
return r
|
||||
|
||||
|
||||
def test_dominant_canon_needs_enough_judged_siblings_and_a_clear_majority():
|
||||
from scribe.services.shape_ledger import dominant_canon
|
||||
|
||||
dense = [_row(f"c/{i}", status="instance", snippet_id=7) for i in range(4)] + [
|
||||
_row("c/x", status="instance", snippet_id=8), _row("c/y")]
|
||||
assert dominant_canon(dense) == (7, 4, 5)
|
||||
sparse = [_row("c/a", status="instance", snippet_id=7), _row("c/b", status="instance", snippet_id=7)]
|
||||
assert dominant_canon(sparse) is None # 2 judged < floor
|
||||
split = [_row(f"c/{i}", status="instance", snippet_id=7) for i in range(2)] + [
|
||||
_row(f"c/{i+5}", status="instance", snippet_id=8) for i in range(2)]
|
||||
assert dominant_canon(split) is None # 50% < 60% share
|
||||
# Variants are departures, not votes; canonical counts like an instance.
|
||||
mixed = [_row("c/a", status="canonical", snippet_id=7)] + [
|
||||
_row(f"c/{i}", status="instance", snippet_id=7) for i in range(2)] + [
|
||||
_row("c/v", status="variant", snippet_id=9)]
|
||||
assert dominant_canon(mixed) == (7, 3, 3)
|
||||
|
||||
|
||||
def test_history_and_readout_tools_are_mounted_and_shape_history_is_read_only():
|
||||
from scribe.mcp.server import _READ_ONLY_TOOLS, build_mcp_server
|
||||
|
||||
mcp = build_mcp_server()
|
||||
assert mcp._tool_manager.get_tool("shape_history") is not None
|
||||
assert "shape_history" in _READ_ONLY_TOOLS
|
||||
assert "flag" in mcp._tool_manager.get_tool("list_shapes").parameters.get("properties", {})
|
||||
|
||||
|
||||
def test_history_and_divergence_columns_are_pinned():
|
||||
from scribe.models.code_shape import SHAPE_EVENTS, CodeShapeEvent
|
||||
|
||||
cols = CodeShape.__table__.c
|
||||
for name in ("classified_sha", "recheck_at", "diverges_from"):
|
||||
assert name in cols, name
|
||||
assert "ix_code_shapes_diverges" in {ix.name for ix in CodeShape.__table__.indexes}
|
||||
ev = CodeShapeEvent.__table__
|
||||
fk = next(iter(ev.c.shape_id.foreign_keys))
|
||||
assert fk.ondelete == "CASCADE" and fk.column.table.name == "code_shapes"
|
||||
assert not ev.c.snippet_id.foreign_keys # history outlives the snippet
|
||||
assert SHAPE_EVENTS == ("classified", "vanished", "reappeared", "drifted")
|
||||
assert "code_shape_events" in Base.metadata.tables
|
||||
|
||||
@@ -1248,3 +1248,42 @@ def test_hook_sends_the_enclosing_definition_for_a_body_edit(tmp_path):
|
||||
},
|
||||
})
|
||||
assert seen["shapes"] == ["sym:onTrash"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_time_divergence_check_is_named_in_band():
|
||||
"""#2793: the hook named a shape at a path whose directory a canon
|
||||
dominates, and the stamp didn't make it that canon's instance — the hint
|
||||
must say so at the write, even when nothing else renders."""
|
||||
from scribe.services import plugin_context as pc
|
||||
div = [{"symbol": "confirmDanger", "kind": "sym", "canon_snippet_id": 2761,
|
||||
"instances": 20, "judged": 21}]
|
||||
check = AsyncMock(return_value=div)
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_divergence", check):
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "frontend/src/components/Danger.vue", code=REAL_CODE, project_id=24,
|
||||
stamp_shapes=[("sym", "confirmDanger")],
|
||||
)
|
||||
check.assert_awaited_once_with(24, "frontend/src/components/Danger.vue",
|
||||
[("sym", "confirmDanger")], [])
|
||||
assert out["divergence"] == div
|
||||
assert "Divergence check at `frontend/src/components/Danger.vue`" in out["context"]
|
||||
assert "`confirmDanger` → #2761 (20 of 21 judged siblings are its instances)" in out["context"]
|
||||
assert "variant" in out["context"]
|
||||
# No project → no check at all.
|
||||
check.reset_mock()
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_divergence", check):
|
||||
out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, stamp_shapes=[("sym", "f")])
|
||||
check.assert_not_awaited()
|
||||
assert out["divergence"] == []
|
||||
|
||||
Reference in New Issue
Block a user