feat(ledger): audit surfaces — list_shapes(compact=True) and classify_shapes_by_rule, the sweep form of a judgment (#2868, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 26s

The 2026-08 audit judged 3,427 rows in 14 hand-driven batches through a raw
MCP client because a list_shapes page overflowed the tool budget and every
row had to be sent back one by one. Now:

- list_shapes(compact=True): path · symbol · kind · status · signature, plus
  snippet_id / by / proposal / diverges_from / recheck only when set. A full
  500-row page fits. CodeShape.to_compact() is the row shape.
- classify_shapes_by_rule(project_id, path, status, pattern=, kind=,
  snippet_id=, reason=, via=, include_judged=): ONE judgment over every
  unclassified live row under a directory whose symbol matches a glob;
  judged rows are untouched unless include_judged; canonical is refused;
  same gates as the row form; one transaction; returns count + sample.
  shape_ledger.classify_shapes_where / rule_matches (pure) carry it.

Tests: compact row pinned, rule_matches directory/glob/kind semantics, the
tool mount, and an integration sweep (unclassified-only, include_judged,
gates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:02:55 -04:00
co-authored by Claude Fable 5
parent 3a4031d7f8
commit 9abc4443fb
5 changed files with 255 additions and 3 deletions
+41
View File
@@ -15,6 +15,7 @@ from scribe.models.project import Project
from scribe.models.user import User
from scribe.services.shape_ledger import (
classify_shapes,
classify_shapes_where,
list_project_shapes,
snippet_consumers,
sync_repo_shapes,
@@ -128,6 +129,46 @@ async def test_classification_is_write_gated_and_listing_read_gated(seeded):
assert await list_project_shapes(other, pid) == ([], 0)
@pytest.mark.integration
async def test_rule_form_sweeps_unclassified_rows_only_and_applies_whole(seeded):
"""#2868: one judgment over a directory + glob; judged rows are left
alone unless include_judged; the same gates as the row form."""
owner, other, pid, sid = seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "settings holder"},
])
out = await classify_shapes_where(
owner, pid, path="src", status="instance", snippet_id=sid, via="audit",
)
# make_app + helper swept; Config (already judged) untouched; css not under src/.
assert out["classified"] == 2
assert out["sample"] == ["src/app.py::make_app", "src/util.py::helper"]
rows, _ = await list_project_shapes(owner, pid)
by_symbol = {r.symbol: r for r in rows}
assert by_symbol["make_app"].status == "instance" and by_symbol["make_app"].classified_by == "audit"
assert by_symbol["Config"].status == "exempt" and by_symbol["Config"].reason == "settings holder"
assert by_symbol["btn"].status == "unclassified"
# Glob + kind narrow; include_judged re-judges.
out = await classify_shapes_where(
owner, pid, path="web", status="exempt", pattern="btn*", kind="css",
reason="one toolbar button", include_judged=True,
)
assert out["classified"] == 1
out = await classify_shapes_where(
owner, pid, path="src", status="unclassified", include_judged=True,
)
assert out["classified"] == 3 # withdrawal sweeps judged rows when asked
# Gates: reason for exempt, snippet for instance, write access, a path.
with pytest.raises(ValueError):
await classify_shapes_where(owner, pid, path="src", status="exempt")
with pytest.raises(ValueError):
await classify_shapes_where(owner, pid, path="src", status="instance")
with pytest.raises(ValueError):
await classify_shapes_where(owner, pid, path="", status="exempt", reason="x")
with pytest.raises(ValueError):
await classify_shapes_where(other, pid, path="src", status="exempt", reason="x")
@pytest.mark.integration
async def test_list_filters_compose(seeded):
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]