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

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:
2026-08-20 23:13:39 -04:00
co-authored by Claude Fable 5
parent 386b27e422
commit d74a244b3a
17 changed files with 880 additions and 62 deletions
+127
View File
@@ -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") == {}