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

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:
2026-08-19 19:25:25 -04:00
co-authored by Claude Fable 5
parent 1faf8f3ece
commit 19fdc9aa89
6 changed files with 282 additions and 9 deletions
+1
View File
@@ -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
+98
View File
@@ -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(),
}