feat(ledger): code_shapes — the shape ledger schema (#2787, milestone 294 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 13s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 45s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 13s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 45s
The accounting half of the pattern system (governing note 2786): the snippet library records canon (small), this table accounts for EVERY extracted shape (total). Identity is (project, repo_key, path, symbol, kind) — kind included because one file can define '.foo' (css) and 'foo' (sym) as distinct shapes. Status vocabulary: canonical / instance / variant / exempt / unclassified, with unclassified as the default and THE todo state; classifications carry who judged (agent|audit|hook|mechanical|import), when, and the why for variants/exemptions. first/last-seen commits + vanished_at keep history instead of deleting it; a rename reads as vanish+new (accepted for v1). snippet_id is SET NULL on snippet deletion so accounting rows outlive their target and rejoin the todo via the step-2 sync, never dangle silently. Backups: v7 carries code_shapes (judgment data, worth moving) — full and per-user export sections, and a restore that keeps a judgment only when its snippet survives the id re-mapping, downgrading to unclassified otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,5 +44,6 @@ from scribe.models.rulebook import ( # noqa: E402, F401
|
||||
)
|
||||
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
|
||||
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
|
||||
from scribe.models.code_shape import CodeShape # noqa: E402, F401
|
||||
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
||||
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import TimestampMixin
|
||||
|
||||
# The classification vocabulary (note 2786). `unclassified` is the default and
|
||||
# THE todo state; every other status is a judgment, stamped with who made it.
|
||||
SHAPE_STATUSES = ("canonical", "instance", "variant", "exempt", "unclassified")
|
||||
SHAPE_CLASSIFIERS = ("agent", "audit", "hook", "mechanical", "import")
|
||||
|
||||
|
||||
class CodeShape(Base, TimestampMixin):
|
||||
"""One extracted code shape and its classification against canon (#2787).
|
||||
|
||||
The accounting half of the pattern system (governing note 2786): the
|
||||
snippet library records CANON (small); this ledger accounts for EVERY
|
||||
shape the coverage extractor finds in a bound repo (total). A row's
|
||||
status says how the shape relates to canon — it IS a snippet's reference
|
||||
(`canonical`), conforms to one (`instance` — snippet_id may point at
|
||||
another project's snippet, so family canon counts), departs deliberately
|
||||
(`variant`, with the why in `reason`), was judged one-off (`exempt`,
|
||||
a recorded judgment rather than silence), or awaits judgment
|
||||
(`unclassified` — the todo).
|
||||
|
||||
Identity is (project, repo_key, path, symbol, kind) — kind is part of it
|
||||
because one file can define `.foo` (css) and `foo` (sym) as distinct
|
||||
shapes. A rename therefore reads as vanish + new row: accepted for v1,
|
||||
because chasing renames needs content identity the extractor doesn't
|
||||
have. `vanished_at` keeps the history instead of deleting it.
|
||||
|
||||
snippet_id is SET NULL on snippet deletion: the classification's target
|
||||
is gone but the judgment happened; the sync pass (step 2) re-files such
|
||||
rows as unclassified so they rejoin the todo instead of dangling.
|
||||
"""
|
||||
|
||||
__tablename__ = "code_shapes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"project_id", "repo_key", "path", "symbol", "kind",
|
||||
name="uq_code_shapes_identity",
|
||||
),
|
||||
Index("ix_code_shapes_project_status", "project_id", "status"),
|
||||
Index("ix_code_shapes_snippet", "snippet_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
repo_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
symbol: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(Text, nullable=False) # "css" | "sym"
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, default="unclassified")
|
||||
snippet_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
classified_by: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
classified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
first_seen_commit: Mapped[str] = mapped_column(Text, default="")
|
||||
last_seen_commit: Mapped[str] = mapped_column(Text, default="")
|
||||
vanished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"project_id": self.project_id,
|
||||
"repo_key": self.repo_key,
|
||||
"path": self.path,
|
||||
"symbol": self.symbol,
|
||||
"kind": self.kind,
|
||||
"status": self.status,
|
||||
"snippet_id": self.snippet_id,
|
||||
"reason": self.reason,
|
||||
"classified_by": self.classified_by,
|
||||
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
|
||||
"first_seen_commit": self.first_seen_commit,
|
||||
"last_seen_commit": self.last_seen_commit,
|
||||
"vanished_at": self.vanished_at.isoformat() if self.vanished_at else None,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -11,6 +11,7 @@ from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.code_shape import CodeShape
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.repo_binding import RepoBinding
|
||||
from scribe.models.rulebook import (
|
||||
@@ -36,8 +37,11 @@ logger = logging.getLogger(__name__)
|
||||
# v6 (2026-08) added note_supersessions — and the guard did stop the seventh:
|
||||
# the table shipped without a backup section and the coverage test failed the
|
||||
# build, which is the whole reason that list was written.
|
||||
# v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications
|
||||
# are judgment data worth carrying; a restore keeps a judgment only when its
|
||||
# snippet target survives the id re-mapping, else the row rejoins the todo.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 6
|
||||
BACKUP_VERSION = 7
|
||||
|
||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||
# below, these two lists must together account for the entire schema — which is
|
||||
@@ -55,6 +59,8 @@ _BACKED_UP = [
|
||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||
"systems", "record_systems", "design_systems", "design_tokens",
|
||||
"note_usage_events", "repo_bindings", "note_supersessions",
|
||||
# v7 (2026-08): the shape ledger (#2787).
|
||||
"code_shapes",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
@@ -166,6 +172,10 @@ def _usage_event_rows(rows) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _code_shape_rows(rows) -> list[dict]:
|
||||
return [r.to_dict() for r in rows]
|
||||
|
||||
|
||||
def _repo_binding_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
|
||||
@@ -205,6 +215,7 @@ async def export_full_backup() -> dict:
|
||||
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
|
||||
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
||||
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
|
||||
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
|
||||
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
|
||||
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
||||
rules = (await session.execute(select(Rule))).scalars().all()
|
||||
@@ -379,6 +390,7 @@ async def export_full_backup() -> dict:
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
"code_shapes": _code_shape_rows(code_shapes),
|
||||
}
|
||||
|
||||
|
||||
@@ -446,6 +458,11 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
repo_bindings = (await session.execute(
|
||||
select(RepoBinding).where(RepoBinding.user_id == user_id)
|
||||
)).scalars().all()
|
||||
# The ledger has no user_id of its own — rows belong to the project
|
||||
# they account for, so a user's export carries their projects' rows.
|
||||
code_shapes = (await session.execute(
|
||||
select(CodeShape).where(CodeShape.project_id.in_(project_ids))
|
||||
)).scalars().all() if project_ids else []
|
||||
rulebooks = (await session.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user_id)
|
||||
)).scalars().all()
|
||||
@@ -634,6 +651,7 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
"code_shapes": _code_shape_rows(code_shapes),
|
||||
}
|
||||
|
||||
|
||||
@@ -737,7 +755,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"topic_suppressions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||
"note_supersessions": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -1114,6 +1132,42 @@ async def _restore_v2(data: dict) -> dict:
|
||||
))
|
||||
stats["repo_bindings"] += 1
|
||||
|
||||
# 21. Code shapes (v7, #2787) — the ledger's classifications are
|
||||
# judgments worth carrying. A judgment whose snippet target didn't
|
||||
# survive the re-mapping (canonical/instance/variant with a gone
|
||||
# snippet) is downgraded to unclassified so it rejoins the todo
|
||||
# honestly instead of dangling; exempt needs no target and keeps.
|
||||
for cs_data in data.get("code_shapes", []):
|
||||
mapped_pid = project_id_map.get(cs_data.get("project_id", 0))
|
||||
if mapped_pid is None:
|
||||
continue
|
||||
status = cs_data.get("status", "unclassified")
|
||||
mapped_sid = note_id_map.get(cs_data.get("snippet_id") or 0)
|
||||
classified_by = cs_data.get("classified_by")
|
||||
classified_at = cs_data.get("classified_at")
|
||||
if status in ("canonical", "instance", "variant") and mapped_sid is None:
|
||||
status = "unclassified"
|
||||
classified_by = None
|
||||
classified_at = None
|
||||
session.add(CodeShape(
|
||||
project_id=mapped_pid,
|
||||
repo_key=cs_data.get("repo_key", ""),
|
||||
path=cs_data.get("path", ""),
|
||||
symbol=cs_data.get("symbol", ""),
|
||||
kind=cs_data.get("kind", "sym"),
|
||||
status=status,
|
||||
snippet_id=mapped_sid,
|
||||
reason=cs_data.get("reason"),
|
||||
classified_by=classified_by,
|
||||
classified_at=_dt(classified_at) if classified_at else None,
|
||||
first_seen_commit=cs_data.get("first_seen_commit", ""),
|
||||
last_seen_commit=cs_data.get("last_seen_commit", ""),
|
||||
vanished_at=_dt(cs_data["vanished_at"]) if cs_data.get("vanished_at") else None,
|
||||
created_at=_dt(cs_data.get("created_at")),
|
||||
updated_at=_dt(cs_data.get("updated_at")),
|
||||
))
|
||||
stats["code_shapes"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2/v3 backup: %s", stats)
|
||||
|
||||
Reference in New Issue
Block a user