CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
The judgment write path. classify_shapes applies a batch of classifications to a project's live ledger rows — all-or-nothing (#2709's lesson: the whole batch is validated, write-ACL'd, and every snippet target proven readable before any row is touched); rows match by exact (path, symbol), kind narrows, and shapes no live row matches come back as 'unmatched' rather than errors. variant/exempt REQUIRE the reason — the why is the record (note 2786) — and 'unclassified' deliberately withdraws a judgment back to the todo. The 'via' channel is caller-restricted to agent|audit|import; hook and mechanical stay server-internal so a caller can't launder judgment as machinery. list_shapes is the todo query (status=unclassified) with composable filters: path is exact-or-under like recorded locations, snippet_id reads a consumer map, include_vanished reads history; paged with the true total. get_snippet now attaches and — the structured consumer map, filtered to projects the CALLER can read so a shared snippet never side- channels another project's file layout; attached only when non-empty (#2483). Integration tests pin the batch atomicity, ACL gates, filter composition, the consumer map on the MCP pull, and the SET NULL companion: a judgment whose snippet was purged rejoins the todo on the next sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
411 lines
16 KiB
Python
411 lines
16 KiB
Python
"""The shape ledger's write side — sync and mechanical marking (#2788).
|
|
|
|
The coverage walk (services/coverage.py) is the only feed that sees every
|
|
shape, so it is the ledger's sync point: each refresh upserts one repo's
|
|
extracted shapes — new shapes arrive `unclassified` (THE todo state, note
|
|
2786), surviving shapes bump their last-seen marker, vanished shapes get
|
|
stamped rather than deleted (history is the point). Classifications survive
|
|
recompute by construction: the upsert never touches a judgment, with two
|
|
deliberate exceptions —
|
|
|
|
- a judgment whose snippet target is gone (SET NULL on snippet deletion)
|
|
is re-filed as unclassified so it rejoins the todo instead of dangling;
|
|
- a MECHANICALLY-stamped canonical row whose snippet location no longer
|
|
covers it falls back to unclassified. Only mechanical stamps self-heal;
|
|
an agent's judgment is never unwound by machinery.
|
|
|
|
`location_covers` is the one covering predicate — the same doctrine the
|
|
recorded-location drift check uses — shared by the sync's canonical marking
|
|
and by anything else that must decide whether a recorded location speaks for
|
|
an extracted shape.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.code_shape import CodeShape
|
|
|
|
# Statuses whose meaning requires a snippet target.
|
|
_NEEDS_TARGET = ("canonical", "instance", "variant")
|
|
|
|
|
|
def _norm_symbol(kind_or_symbol: str) -> str:
|
|
# CSS shapes and recorded CSS symbols may or may not carry the leading
|
|
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
|
|
return kind_or_symbol.lstrip(".").strip()
|
|
|
|
|
|
def location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> bool:
|
|
"""Does a recorded (path, symbol) location speak for this shape?
|
|
|
|
Symbol-less locations never cover a shape — a whole-file record makes no
|
|
claim about any particular definition inside it. Path semantics are the
|
|
drift check's own: exact file, or the recorded path is a directory the
|
|
file lives under.
|
|
"""
|
|
if not (loc_symbol or "").strip():
|
|
return False
|
|
if _norm_symbol(loc_symbol) != _norm_symbol(name):
|
|
return False
|
|
if not loc_path:
|
|
# Symbol-only record: the symbol match is all the claim there is.
|
|
return True
|
|
from scribe.services.snippets import _path_touches
|
|
|
|
return _path_touches(loc_path, path)
|
|
|
|
|
|
async def sync_repo_shapes(
|
|
project_id: int,
|
|
repo_key: str,
|
|
shapes: list[tuple[str, str, str]],
|
|
*,
|
|
seen_marker: str,
|
|
) -> None:
|
|
"""Upsert one repo's extracted (path, kind, name) shapes into the ledger.
|
|
|
|
``seen_marker`` is the commit the archive was read at when the forge can
|
|
say, else the ref name — provenance sugar; the row timestamps carry the
|
|
when.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
async with async_session() as session:
|
|
rows = (
|
|
await session.execute(
|
|
select(CodeShape).where(
|
|
CodeShape.project_id == project_id,
|
|
CodeShape.repo_key == repo_key,
|
|
)
|
|
)
|
|
).scalars().all()
|
|
by_key = {(r.path, r.symbol, r.kind): r for r in rows}
|
|
seen: set[tuple[str, str, str]] = set()
|
|
for path, kind, name in shapes:
|
|
key = (path, name, kind)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
row = by_key.get(key)
|
|
if row is None:
|
|
session.add(CodeShape(
|
|
project_id=project_id, repo_key=repo_key,
|
|
path=path, symbol=name, kind=kind,
|
|
first_seen_commit=seen_marker, last_seen_commit=seen_marker,
|
|
))
|
|
continue
|
|
row.last_seen_commit = seen_marker
|
|
# A shape that vanished and came back is live again — the vanish
|
|
# stays visible in history via updated_at, not as a dead flag.
|
|
row.vanished_at = None
|
|
if row.status in _NEEDS_TARGET and row.snippet_id is None:
|
|
row.status = "unclassified"
|
|
row.classified_by = None
|
|
row.classified_at = None
|
|
row.reason = None
|
|
for key, row in by_key.items():
|
|
if key not in seen and row.vanished_at is None:
|
|
row.vanished_at = now
|
|
await session.commit()
|
|
|
|
|
|
async def mark_canonicals(
|
|
project_id: int, recorded: list[tuple[int, str, str]]
|
|
) -> None:
|
|
"""Stamp snippet reference locations as `canonical` — the one mechanical
|
|
rule that is always safe (the judgment happened when the snippet was
|
|
minted; this row just makes it queryable).
|
|
|
|
``recorded`` is (snippet_note_id, path, symbol) for every live snippet
|
|
location in the project. Touches only rows machinery owns: unclassified
|
|
rows gain the stamp; mechanically-stamped canonicals no longer covered
|
|
fall back to unclassified. Agent judgments are never overwritten.
|
|
"""
|
|
usable = [(nid, p, s) for nid, p, s in recorded if (s or "").strip()]
|
|
now = datetime.now(timezone.utc)
|
|
async with async_session() as session:
|
|
rows = (
|
|
await session.execute(
|
|
select(CodeShape).where(
|
|
CodeShape.project_id == project_id,
|
|
CodeShape.vanished_at.is_(None),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
for row in rows:
|
|
covering = next(
|
|
(
|
|
nid for nid, lp, ls in usable
|
|
if location_covers(lp, ls, row.path, row.symbol)
|
|
),
|
|
None,
|
|
)
|
|
if covering is not None and row.status == "unclassified":
|
|
row.status = "canonical"
|
|
row.snippet_id = covering
|
|
row.classified_by = "mechanical"
|
|
row.classified_at = now
|
|
elif (
|
|
covering is None
|
|
and row.status == "canonical"
|
|
and row.classified_by == "mechanical"
|
|
):
|
|
row.status = "unclassified"
|
|
row.snippet_id = None
|
|
row.classified_by = None
|
|
row.classified_at = None
|
|
await session.commit()
|
|
|
|
|
|
async def live_rows(project_id: int) -> list[CodeShape]:
|
|
"""Every un-vanished ledger row for a project — the accounting readout's
|
|
input, across ALL its repos (a repo unreachable this refresh still counts;
|
|
accounting is project-wide)."""
|
|
async with async_session() as session:
|
|
return list(
|
|
(
|
|
await session.execute(
|
|
select(CodeShape).where(
|
|
CodeShape.project_id == project_id,
|
|
CodeShape.vanished_at.is_(None),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
)
|
|
|
|
|
|
# --- classification (#2789): the judgment write path --------------------------
|
|
|
|
# Statuses an explicit classification may set. All five: setting a row back to
|
|
# `unclassified` is how a judgment is deliberately withdrawn.
|
|
_SETTABLE = ("canonical", "instance", "variant", "exempt", "unclassified")
|
|
|
|
# Who may appear as the classifier on this path. `hook` and `mechanical` are
|
|
# server-internal feeds (steps 5-6) — a caller claiming them would launder a
|
|
# judgment as machinery.
|
|
_CALLER_VIAS = ("agent", "audit", "import")
|
|
|
|
|
|
def validate_classifications(items: list[dict]) -> str | None:
|
|
"""The structural error a classification batch would earn, or None.
|
|
|
|
Pure and checked BEFORE anything is touched: a batch either applies or
|
|
errors whole — the StrictArgs lesson (#2709), a caller must never learn
|
|
later that half a batch silently happened.
|
|
"""
|
|
if not items:
|
|
return "classifications is empty — nothing to apply"
|
|
for i, item in enumerate(items):
|
|
if not isinstance(item, dict):
|
|
return f"classifications[{i}] is not an object"
|
|
path = (item.get("path") or "").strip()
|
|
symbol = (item.get("symbol") or "").strip()
|
|
if not path or not symbol:
|
|
return f"classifications[{i}] needs both path and symbol"
|
|
status = item.get("status") or ""
|
|
if status not in _SETTABLE:
|
|
return (
|
|
f"classifications[{i}] has unknown status {status!r} "
|
|
f"(one of: {', '.join(_SETTABLE)})"
|
|
)
|
|
snippet_id = item.get("snippet_id") or 0
|
|
if status in _NEEDS_TARGET and not snippet_id:
|
|
return (
|
|
f"classifications[{i}]: status {status!r} needs snippet_id — "
|
|
"the snippet this shape is (or departs from)"
|
|
)
|
|
if status in ("variant", "exempt") and not (item.get("reason") or "").strip():
|
|
return (
|
|
f"classifications[{i}]: status {status!r} needs a reason — "
|
|
"the WHY is the record (note 2786)"
|
|
)
|
|
return None
|
|
|
|
|
|
async def classify_shapes(
|
|
user_id: int,
|
|
project_id: int,
|
|
classifications: list[dict],
|
|
*,
|
|
via: str = "agent",
|
|
) -> dict:
|
|
"""Apply a batch of judgments to a project's live ledger rows.
|
|
|
|
All-or-nothing on errors: the whole batch is validated (structure, write
|
|
access, every snippet target readable by the caller) before any row is
|
|
touched. Rows are matched by exact (path, symbol) — plus kind when the
|
|
item carries one — and a target no live row matches is reported in
|
|
``unmatched``, not an error: the tree may simply have moved since the
|
|
caller listed. Idempotent by construction.
|
|
"""
|
|
from scribe.services import access
|
|
from scribe.services import snippets as snippets_svc
|
|
|
|
if via not in _CALLER_VIAS:
|
|
raise ValueError(f"via must be one of: {', '.join(_CALLER_VIAS)}")
|
|
error = validate_classifications(classifications)
|
|
if error:
|
|
raise ValueError(error)
|
|
if not await access.can_write_project(user_id, project_id):
|
|
raise ValueError(f"project {project_id} not found or no write access")
|
|
|
|
# Snippet targets resolve through the caller's own read access — a
|
|
# family-canon snippet in another project counts (note 2786), a snippet
|
|
# the caller cannot read does not exist for them.
|
|
target_ids = {
|
|
int(item["snippet_id"])
|
|
for item in classifications
|
|
if item.get("status") in _NEEDS_TARGET
|
|
}
|
|
for sid in sorted(target_ids):
|
|
if await snippets_svc.get_snippet(user_id, sid) is None:
|
|
raise ValueError(f"snippet {sid} not found (or not readable)")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
classified = 0
|
|
unmatched: list[dict] = []
|
|
async with async_session() as session:
|
|
rows = (
|
|
await session.execute(
|
|
select(CodeShape).where(
|
|
CodeShape.project_id == project_id,
|
|
CodeShape.vanished_at.is_(None),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
by_key: dict[tuple[str, str], list[CodeShape]] = {}
|
|
for row in rows:
|
|
by_key.setdefault((row.path, row.symbol), []).append(row)
|
|
for item in classifications:
|
|
matches = by_key.get(
|
|
((item.get("path") or "").strip(), (item.get("symbol") or "").strip())
|
|
) or []
|
|
kind = (item.get("kind") or "").strip()
|
|
if kind:
|
|
matches = [r for r in matches if r.kind == kind]
|
|
if not matches:
|
|
unmatched.append({
|
|
"path": item.get("path"), "symbol": item.get("symbol"),
|
|
})
|
|
continue
|
|
status = item["status"]
|
|
for row in matches:
|
|
row.status = status
|
|
if status == "unclassified":
|
|
row.snippet_id = None
|
|
row.reason = None
|
|
row.classified_by = None
|
|
row.classified_at = None
|
|
else:
|
|
row.snippet_id = (
|
|
int(item["snippet_id"]) if status in _NEEDS_TARGET else None
|
|
)
|
|
row.reason = (item.get("reason") or "").strip() or None
|
|
row.classified_by = via
|
|
row.classified_at = now
|
|
classified += 1
|
|
await session.commit()
|
|
return {"classified": classified, "unmatched": unmatched}
|
|
|
|
|
|
async def list_project_shapes(
|
|
user_id: int,
|
|
project_id: int,
|
|
*,
|
|
status: str = "",
|
|
path: str = "",
|
|
snippet_id: int = 0,
|
|
include_vanished: bool = False,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
) -> tuple[list[CodeShape], int]:
|
|
"""A filtered page of a project's ledger, with the unfiltered-match total.
|
|
|
|
([], 0) when the caller can't read the project — the same silence every
|
|
other project list gives. ``path`` matches the exact file or anything
|
|
beneath it, mirroring recorded-location semantics.
|
|
"""
|
|
from sqlalchemy import func, or_
|
|
|
|
from scribe.services import access
|
|
|
|
if not await access.can_read_project(user_id, project_id):
|
|
return [], 0
|
|
conds = [CodeShape.project_id == project_id]
|
|
if not include_vanished:
|
|
conds.append(CodeShape.vanished_at.is_(None))
|
|
if status:
|
|
conds.append(CodeShape.status == status)
|
|
if path:
|
|
clean = path.strip().strip("/")
|
|
conds.append(or_(
|
|
CodeShape.path == clean, CodeShape.path.like(clean + "/%")
|
|
))
|
|
if snippet_id:
|
|
conds.append(CodeShape.snippet_id == snippet_id)
|
|
async with async_session() as session:
|
|
total = (
|
|
await session.execute(
|
|
select(func.count()).select_from(CodeShape).where(*conds)
|
|
)
|
|
).scalar_one()
|
|
rows = (
|
|
await session.execute(
|
|
select(CodeShape).where(*conds)
|
|
.order_by(CodeShape.path, CodeShape.symbol, CodeShape.kind)
|
|
.limit(max(1, min(limit, 500))).offset(max(0, offset))
|
|
)
|
|
).scalars().all()
|
|
return list(rows), int(total)
|
|
|
|
|
|
def _consumer_dict(row: CodeShape) -> dict:
|
|
"""The compact shape a snippet's consumer map carries — enough to open
|
|
the file, none of the ledger bookkeeping."""
|
|
out = {
|
|
"project_id": row.project_id,
|
|
"repo": row.repo_key,
|
|
"path": row.path,
|
|
"symbol": row.symbol,
|
|
"kind": row.kind,
|
|
"classified_by": row.classified_by,
|
|
}
|
|
if row.reason:
|
|
out["reason"] = row.reason
|
|
return out
|
|
|
|
|
|
async def snippet_consumers(user_id: int, note_id: int) -> dict:
|
|
"""The structured consumer map for one snippet (#2789): its `instances`
|
|
(rows judged to conform) and `variants` (named departures, each carrying
|
|
its why). Rows are filtered to projects the CALLER can read — a shared
|
|
snippet must not become a side channel into someone else's project
|
|
layout. Empty lists mean "attach nothing" (#2483)."""
|
|
from scribe.services import access
|
|
|
|
async with async_session() as session:
|
|
rows = (
|
|
await session.execute(
|
|
select(CodeShape).where(
|
|
CodeShape.snippet_id == note_id,
|
|
CodeShape.status.in_(("instance", "variant")),
|
|
CodeShape.vanished_at.is_(None),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
readable: dict[int, bool] = {}
|
|
out: dict[str, list[dict]] = {"instances": [], "variants": []}
|
|
for row in rows:
|
|
if row.project_id not in readable:
|
|
readable[row.project_id] = await access.can_read_project(
|
|
user_id, row.project_id
|
|
)
|
|
if not readable[row.project_id]:
|
|
continue
|
|
out["instances" if row.status == "instance" else "variants"].append(
|
|
_consumer_dict(row)
|
|
)
|
|
return out
|