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(), }