feat(ledger): divergence readout — button B where button A is canon, shape history, and judged-shape recheck (#2793, milestone 294 step 7)
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Every judgment now goes through one helper that remembers the fingerprint judged (classified_sha) and writes a code_shape_events row; the sync writes vanished / reappeared / drifted events and flags recheck_at when a body moves under an instance/variant. The refresh flags diverges_from on shapes new since the previous computation that sit where one canon dominates the judged siblings of their directory+kind and were not proposed as that canon (a first seed flags nothing); the write-path hint asks the same question in-band for the shapes the hook names. list_shapes(flag=divergence|recheck), shape_history(project_id, path, symbol) (read-only), coverage line/payload/ card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
|
||||
|
||||
Revision ID: 0081
|
||||
Revises: 0080
|
||||
Create Date: 2026-08-21
|
||||
|
||||
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
|
||||
a judgment was made at so a later body change under an instance/variant can
|
||||
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
|
||||
the previous refresh, where one canon dominates its directory+kind, and not
|
||||
proposed as that canon). `code_shape_events` is the what-was-used-when
|
||||
record: every classification, vanish, reappearance, and drift as it
|
||||
happened — history the row alone cannot keep once it moves on.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0081"
|
||||
down_revision = "0080"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column(
|
||||
"code_shapes",
|
||||
sa.Column(
|
||||
"diverges_from",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
|
||||
op.create_table(
|
||||
"code_shape_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"shape_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("code_shapes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("project_id", sa.Integer(), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("symbol", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=True),
|
||||
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("classified_by", sa.Text(), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
|
||||
op.create_index(
|
||||
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
|
||||
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
|
||||
op.drop_table("code_shape_events")
|
||||
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
|
||||
for col in ("diverges_from", "recheck_at", "classified_sha"):
|
||||
op.drop_column("code_shapes", col)
|
||||
@@ -447,6 +447,17 @@ interface Coverage {
|
||||
// agent's confirm, and the biggest repeats-with-no-canon families.
|
||||
proposed?: number;
|
||||
derive_groups?: DeriveGroup[];
|
||||
// The divergence readout (#2793): button B where button A is canon, and
|
||||
// judged shapes whose bodies moved since they were judged.
|
||||
divergent?: number;
|
||||
divergence?: Divergence[];
|
||||
recheck?: number;
|
||||
}
|
||||
interface Divergence {
|
||||
path: string;
|
||||
symbol: string;
|
||||
kind: string;
|
||||
canon_snippet_id: number;
|
||||
}
|
||||
|
||||
const coverage = ref<Coverage | null>(null);
|
||||
@@ -775,6 +786,28 @@ async function confirmDelete() {
|
||||
{{ g.label }} <span class="coverage-gap-count">×{{ g.size }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="coverage.divergent || coverage.recheck"
|
||||
class="coverage-gaps"
|
||||
title="Divergent: a new shape where one canon dominates its directory and that isn't proposed as that canon — button B where button A is canon. Recheck: a judged shape whose body changed since it was judged."
|
||||
>
|
||||
<span class="coverage-gaps-label">Divergence:</span>
|
||||
<span
|
||||
v-for="d in coverage.divergence || []"
|
||||
:key="d.path + '::' + d.symbol"
|
||||
class="coverage-gap-chip coverage-divergent"
|
||||
:title="d.path + ' — canon here is snippet #' + d.canon_snippet_id"
|
||||
>
|
||||
{{ d.kind === 'css' ? '.' : '' }}{{ d.symbol }}
|
||||
<span class="coverage-gap-count">→ #{{ d.canon_snippet_id }}</span>
|
||||
</span>
|
||||
<span v-if="(coverage.divergent || 0) > (coverage.divergence?.length || 0)" class="coverage-gap-chip">
|
||||
+{{ (coverage.divergent || 0) - (coverage.divergence?.length || 0) }} more
|
||||
</span>
|
||||
<span v-if="coverage.recheck" class="coverage-gap-chip">
|
||||
{{ coverage.recheck }} to recheck
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="coverage-empty">
|
||||
Not measured yet — Refresh reads the bound repo's definitions into
|
||||
@@ -1329,6 +1362,7 @@ async function confirmDelete() {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.coverage-gap-count { opacity: 0.65; }
|
||||
.coverage-divergent { border-color: var(--fs-warning); }
|
||||
.coverage-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.35",
|
||||
"version": "0.1.36",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
@@ -80,6 +80,25 @@ the dominant form, `create_snippet` it, migrate the outliers, then classify
|
||||
the rest as instances. Canon is determined from the code; consistency comes
|
||||
from the derivation, not from asking permission.
|
||||
|
||||
## The divergence readout — button B where button A is canon
|
||||
|
||||
Three questions the ledger answers mechanically (#2793):
|
||||
|
||||
- **Divergence** — `list_shapes(project_id, flag="divergence")` (and the
|
||||
coverage line's "N DIVERGENT"): a shape new since the previous refresh, in
|
||||
a directory where one canon dominates the judged siblings, that the
|
||||
proposer did not match to that canon. `diverges_from` names the canon.
|
||||
Judge it: `instance` if it should be built from the canon (and rebuild
|
||||
it), `variant` with the why if the departure is deliberate. The write-path
|
||||
hook asks the same question in-band the moment such a shape is written.
|
||||
- **History** — `shape_history(project_id, path, symbol?)`: the current rows
|
||||
plus every `classified` / `vanished` / `reappeared` / `drifted` event with
|
||||
its commit — "instance of #N from <date>, re-judged variant of #M because
|
||||
R, vanished at C". Rows, not recollection.
|
||||
- **Recheck** — `list_shapes(project_id, flag="recheck")`: judged
|
||||
instances/variants whose body changed since judged. The judgment stands;
|
||||
re-confirm it (classify again with the same status) or re-judge.
|
||||
|
||||
## What this buys
|
||||
|
||||
Divergence becomes mechanical: when button B appears where button A is canon,
|
||||
|
||||
@@ -112,7 +112,7 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
"list_repo_bindings",
|
||||
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
|
||||
# the write, and it is deliberately NOT here.
|
||||
"list_shapes",
|
||||
"list_shapes", "shape_history",
|
||||
})
|
||||
|
||||
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
|
||||
|
||||
@@ -63,6 +63,7 @@ async def list_shapes(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
proposal: str = "",
|
||||
flag: str = "",
|
||||
) -> dict:
|
||||
"""Read a project's shape ledger — `status="unclassified"` IS the todo.
|
||||
|
||||
@@ -81,6 +82,14 @@ async def list_shapes(
|
||||
snippet_id, basis, score), "derive" (rows that repeat with NO
|
||||
canon: `proposal.group` names the family), or one basis
|
||||
(symbol/text/reference/signature/semantic).
|
||||
flag: the divergence readout (#2793) — "divergence": shapes new
|
||||
since the previous refresh in a directory where one canon
|
||||
dominates the judged siblings and NOT proposed as that canon
|
||||
(`diverges_from` names it: button B where button A is canon —
|
||||
classify it: instance if it should use the canon, variant with
|
||||
the why if deliberate); "recheck": judged instances/variants
|
||||
whose body changed since judged (the judgment stands; confirm
|
||||
it again with classify_shapes, or re-judge).
|
||||
|
||||
Returns {"shapes": [...], "total": N} — total counts every match, not
|
||||
just this page. Each row's `classified_by` says who judged: agent /
|
||||
@@ -107,11 +116,36 @@ async def list_shapes(
|
||||
uid, project_id,
|
||||
status=status, path=path, snippet_id=snippet_id,
|
||||
include_vanished=include_vanished, limit=limit, offset=offset,
|
||||
proposal=proposal,
|
||||
proposal=proposal, flag=flag,
|
||||
)
|
||||
return {"shapes": [r.to_dict() for r in rows], "total": total}
|
||||
|
||||
|
||||
async def shape_history(
|
||||
project_id: int, path: str, symbol: str = "", limit: int = 200
|
||||
) -> dict:
|
||||
"""What was used here, when, and why — a shape's (or a directory's)
|
||||
history from the ledger (#2793).
|
||||
|
||||
`shapes` are the current rows at `path` (a file, or a directory and
|
||||
everything beneath it; `symbol` narrows to one definition) with
|
||||
first/last-seen commits, vanished_at, and the standing judgment;
|
||||
`events` are the state changes, oldest first: `classified` (status,
|
||||
snippet_id, who, why — one per judgment, so a shape that was an instance
|
||||
of #N and later a variant of #M shows both), `vanished`, `reappeared`,
|
||||
`drifted` (the body moved under a judgment; see list_shapes flag=
|
||||
"recheck"). Each event carries the commit the tree was read at.
|
||||
|
||||
Read it as a timeline: "instance of #N from <first classified at>,
|
||||
re-judged variant of #M at <at> because <reason>, vanished at <commit>".
|
||||
Read-only; requires read access to the project.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
return await shape_ledger_svc.shape_history(
|
||||
uid, project_id, path, symbol=symbol, limit=limit
|
||||
)
|
||||
|
||||
|
||||
async def confirm_shape_proposals(
|
||||
project_id: int,
|
||||
snippet_id: int = 0,
|
||||
@@ -184,6 +218,6 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
classify_shapes, list_shapes, refresh_pattern_coverage,
|
||||
confirm_shape_proposals,
|
||||
confirm_shape_proposals, shape_history,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -44,6 +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.code_shape import CodeShape, CodeShapeEvent # noqa: E402, F401
|
||||
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
||||
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
||||
|
||||
@@ -56,6 +56,15 @@ class CodeShape(Base, TimestampMixin):
|
||||
matches on and what a later drift recheck compares against; the ledger
|
||||
still never stores code bodies.
|
||||
|
||||
`classified_sha` remembers the fingerprint a judgment was made at;
|
||||
when a later sync sees the body change under an instance/variant, the
|
||||
row is flagged `recheck_at` (the judgment stands, it just asks to be
|
||||
confirmed again) and a `drifted` event is written. `diverges_from`
|
||||
(#2793) is the button-B flag: a shape new since the previous refresh, in
|
||||
a directory+kind where one canon dominates the judged siblings, that the
|
||||
proposer did not match to that canon — "button B appeared where button
|
||||
A is canon: divergence or variant? classify it." Both clear on judgment.
|
||||
|
||||
The proposal columns hold the proposer's standing suggestion for an
|
||||
UNCLASSIFIED row: `proposed_snippet_id` + `proposal_basis` + score for
|
||||
"looks like an instance of #N", or `proposal_basis="derive"` +
|
||||
@@ -74,6 +83,7 @@ class CodeShape(Base, TimestampMixin):
|
||||
Index("ix_code_shapes_project_status", "project_id", "status"),
|
||||
Index("ix_code_shapes_snippet", "snippet_id"),
|
||||
Index("ix_code_shapes_proposed", "project_id", "proposed_snippet_id"),
|
||||
Index("ix_code_shapes_diverges", "project_id", "diverges_from"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -110,6 +120,13 @@ class CodeShape(Base, TimestampMixin):
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
proposed_sha: Mapped[str] = mapped_column(Text, default="")
|
||||
classified_sha: Mapped[str] = mapped_column(Text, default="")
|
||||
recheck_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
diverges_from: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
@property
|
||||
def proposal(self) -> dict | None:
|
||||
@@ -143,6 +160,66 @@ class CodeShape(Base, TimestampMixin):
|
||||
"signature": self.signature,
|
||||
"body_sha": self.body_sha,
|
||||
"proposal": self.proposal,
|
||||
"classified_sha": self.classified_sha,
|
||||
"recheck_at": self.recheck_at.isoformat() if self.recheck_at else None,
|
||||
"diverges_from": self.diverges_from,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# What a shape's history records (#2793). Not "appeared" — first_seen and
|
||||
# created_at already say that on the row; history is for what CHANGED:
|
||||
SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted")
|
||||
|
||||
|
||||
class CodeShapeEvent(Base):
|
||||
"""One state change in a shape's life — the what-was-used-when record.
|
||||
|
||||
"We used #N here from <date>, #M replaced it at commit C, reason R" is a
|
||||
question the ledger row alone cannot answer once it has moved on; this
|
||||
table keeps each judgment (status, snippet, who, why, at which commit)
|
||||
and each presence change (vanished / reappeared / drifted) as it
|
||||
happened. Denormalised path/symbol/kind so a directory's history reads
|
||||
without joining; `snippet_id` is deliberately FK-free — history outlives
|
||||
the snippet it names, which is the point.
|
||||
"""
|
||||
|
||||
__tablename__ = "code_shape_events"
|
||||
__table_args__ = (
|
||||
Index("ix_code_shape_events_shape", "shape_id", "at"),
|
||||
Index("ix_code_shape_events_project_path", "project_id", "path"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
shape_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
project_id: Mapped[int] = mapped_column(Integer, 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)
|
||||
event: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
snippet_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
classified_by: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
commit: Mapped[str] = mapped_column(Text, default="")
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"shape_id": self.shape_id,
|
||||
"project_id": self.project_id,
|
||||
"path": self.path,
|
||||
"symbol": self.symbol,
|
||||
"kind": self.kind,
|
||||
"event": self.event,
|
||||
"status": self.status,
|
||||
"snippet_id": self.snippet_id,
|
||||
"classified_by": self.classified_by,
|
||||
"reason": self.reason,
|
||||
"commit": self.commit,
|
||||
"at": self.at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -11,7 +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.code_shape import CodeShape, CodeShapeEvent
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.repo_binding import RepoBinding
|
||||
from scribe.models.rulebook import (
|
||||
@@ -40,8 +40,10 @@ logger = logging.getLogger(__name__)
|
||||
# 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.
|
||||
# v8 (2026-08) added code_shape_events — the ledger's history (#2793): what
|
||||
# was used where, when, and why is not recomputable, so it travels.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 7
|
||||
BACKUP_VERSION = 8
|
||||
|
||||
# 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
|
||||
@@ -59,8 +61,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",
|
||||
# v7 (2026-08): the shape ledger (#2787); v8: its history (#2793).
|
||||
"code_shapes", "code_shape_events",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
@@ -176,6 +178,10 @@ def _code_shape_rows(rows) -> list[dict]:
|
||||
return [r.to_dict() for r in rows]
|
||||
|
||||
|
||||
def _code_shape_event_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}
|
||||
@@ -216,6 +222,9 @@ async def export_full_backup() -> dict:
|
||||
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()
|
||||
code_shape_events = (await session.execute(
|
||||
select(CodeShapeEvent).order_by(CodeShapeEvent.at, CodeShapeEvent.id)
|
||||
)).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()
|
||||
@@ -391,6 +400,7 @@ async def export_full_backup() -> dict:
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
"code_shapes": _code_shape_rows(code_shapes),
|
||||
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
||||
}
|
||||
|
||||
|
||||
@@ -463,6 +473,10 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
code_shapes = (await session.execute(
|
||||
select(CodeShape).where(CodeShape.project_id.in_(project_ids))
|
||||
)).scalars().all() if project_ids else []
|
||||
code_shape_events = (await session.execute(
|
||||
select(CodeShapeEvent).where(CodeShapeEvent.project_id.in_(project_ids))
|
||||
.order_by(CodeShapeEvent.at, CodeShapeEvent.id)
|
||||
)).scalars().all() if project_ids else []
|
||||
rulebooks = (await session.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user_id)
|
||||
)).scalars().all()
|
||||
@@ -652,6 +666,7 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
"code_shapes": _code_shape_rows(code_shapes),
|
||||
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
||||
}
|
||||
|
||||
|
||||
@@ -755,7 +770,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, "code_shapes": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -1137,6 +1152,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
# 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.
|
||||
shape_id_map: dict[int, int] = {}
|
||||
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:
|
||||
@@ -1149,7 +1165,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
status = "unclassified"
|
||||
classified_by = None
|
||||
classified_at = None
|
||||
session.add(CodeShape(
|
||||
shape = CodeShape(
|
||||
project_id=mapped_pid,
|
||||
repo_key=cs_data.get("repo_key", ""),
|
||||
path=cs_data.get("path", ""),
|
||||
@@ -1168,11 +1184,41 @@ async def _restore_v2(data: dict) -> dict:
|
||||
# against the restored snippet ids.
|
||||
signature=cs_data.get("signature", ""),
|
||||
body_sha=cs_data.get("body_sha", ""),
|
||||
classified_sha=cs_data.get("classified_sha", ""),
|
||||
created_at=_dt(cs_data.get("created_at")),
|
||||
updated_at=_dt(cs_data.get("updated_at")),
|
||||
))
|
||||
)
|
||||
session.add(shape)
|
||||
await session.flush()
|
||||
if cs_data.get("id"):
|
||||
shape_id_map[int(cs_data["id"])] = shape.id
|
||||
stats["code_shapes"] += 1
|
||||
|
||||
# v8: the ledger's history rides its shapes. snippet_id is kept as
|
||||
# the history's own claim (FK-free by design) but re-mapped when the
|
||||
# snippet survived, so a restored timeline points at restored records.
|
||||
for ev in data.get("code_shape_events", []):
|
||||
new_shape_id = shape_id_map.get(ev.get("shape_id") or 0)
|
||||
mapped_pid = project_id_map.get(ev.get("project_id", 0))
|
||||
if new_shape_id is None or mapped_pid is None:
|
||||
continue
|
||||
old_sid = ev.get("snippet_id")
|
||||
session.add(CodeShapeEvent(
|
||||
shape_id=new_shape_id,
|
||||
project_id=mapped_pid,
|
||||
path=ev.get("path", ""),
|
||||
symbol=ev.get("symbol", ""),
|
||||
kind=ev.get("kind", "sym"),
|
||||
event=ev.get("event", "classified"),
|
||||
status=ev.get("status"),
|
||||
snippet_id=note_id_map.get(old_sid, old_sid) if old_sid else None,
|
||||
classified_by=ev.get("classified_by"),
|
||||
reason=ev.get("reason"),
|
||||
commit=ev.get("commit", ""),
|
||||
at=_dt(ev.get("at")),
|
||||
))
|
||||
stats["code_shape_events"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2/v3 backup: %s", stats)
|
||||
|
||||
@@ -397,6 +397,18 @@ async def compute_coverage(
|
||||
await shape_ledger.apply_derive_groups(project_id)
|
||||
except Exception:
|
||||
logger.warning("derive-first grouping failed", exc_info=True)
|
||||
# The button-B pass (#2793): shapes new since the PREVIOUS computation,
|
||||
# where a canon dominates. The previous computation's stamp is the cache;
|
||||
# a first seed has none, so it flags nothing (everything is new then).
|
||||
try:
|
||||
previous = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}")
|
||||
since = None
|
||||
if previous:
|
||||
stamp = (json.loads(previous) or {}).get("computed_at")
|
||||
since = datetime.fromisoformat(stamp) if stamp else None
|
||||
await shape_ledger.flag_divergence(project_id, since=since)
|
||||
except Exception:
|
||||
logger.warning("divergence pass failed", exc_info=True)
|
||||
|
||||
# Project-wide readout, deliberately wider than this walk: a second bound
|
||||
# repo that was unreachable today still has live rows, and they count.
|
||||
@@ -413,6 +425,7 @@ async def compute_coverage(
|
||||
|
||||
unclassified = counts.pop("unclassified")
|
||||
proposals = shape_ledger.proposal_summary(rows)
|
||||
divergence = shape_ledger.divergence_summary(rows)
|
||||
return {
|
||||
"total": len(rows),
|
||||
"accounted": len(rows) - unclassified,
|
||||
@@ -423,6 +436,11 @@ async def compute_coverage(
|
||||
"proposed": proposals["proposed"],
|
||||
"derive_groups": proposals["derive_groups"],
|
||||
"proposer": proposer_stats,
|
||||
# The divergence readout (#2793): button B where button A is canon,
|
||||
# and judged shapes whose bodies moved since they were judged.
|
||||
"divergent": divergence["divergent"],
|
||||
"divergence": divergence["divergence"],
|
||||
"recheck": divergence["recheck"],
|
||||
# Honesty flag, not decoration: every surface that shows the number
|
||||
# is expected to carry it through.
|
||||
"estimate": True,
|
||||
@@ -572,9 +590,13 @@ def coverage_line(coverage: dict) -> str:
|
||||
n_groups = len(coverage.get("derive_groups") or [])
|
||||
if n_groups:
|
||||
standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}")
|
||||
if coverage.get("divergent"):
|
||||
standing.append(f"{coverage['divergent']} DIVERGENT")
|
||||
if standing:
|
||||
line += f" ({', '.join(standing)})"
|
||||
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
|
||||
if gaps:
|
||||
line += ", largest: " + ", ".join(gaps)
|
||||
if coverage.get("recheck"):
|
||||
line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck"
|
||||
return line
|
||||
|
||||
@@ -923,7 +923,19 @@ async def build_write_path_hint(
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Write-path ledger stamping failed", exc_info=True)
|
||||
if not synced and not menu and not stamped:
|
||||
# The in-band button-B check (#2793): the hook named the shapes being
|
||||
# written; if this directory+kind is canon-dense and a named shape isn't
|
||||
# (about to be) an instance of that canon, say so NOW — at the write,
|
||||
# not at the next audit.
|
||||
divergence: list[dict] = []
|
||||
if stamp_shapes and project_id:
|
||||
try:
|
||||
divergence = await shape_ledger_svc.write_time_divergence(
|
||||
project_id, path, stamp_shapes, stamped
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("write-time divergence check failed", exc_info=True)
|
||||
if not synced and not menu and not stamped and not divergence:
|
||||
return empty
|
||||
|
||||
owners = await owner_names_for({
|
||||
@@ -989,6 +1001,8 @@ async def build_write_path_hint(
|
||||
|
||||
if stamped:
|
||||
lines.append(_stamp_line(path, stamped))
|
||||
if divergence:
|
||||
lines.append(_divergence_line(path, divergence))
|
||||
|
||||
# Split by arm, which is the whole reason this table exists. The place arm
|
||||
# carries no score and so has no home in retrieval_logs; before #2085 a
|
||||
@@ -1012,9 +1026,26 @@ async def build_write_path_hint(
|
||||
"sync_note_ids": sync_note_ids,
|
||||
"config": cfg,
|
||||
"stamped": stamped,
|
||||
"divergence": divergence,
|
||||
}
|
||||
|
||||
|
||||
def _divergence_line(path: str, divergence: list[dict]) -> str:
|
||||
"""Button B where button A is canon — named at the write (#2793)."""
|
||||
parts = [
|
||||
f"`{('.' if d['kind'] == 'css' else '') + d['symbol']}` → #{d['canon_snippet_id']} "
|
||||
f"({d['instances']} of {d['judged']} judged siblings are its instances)"
|
||||
for d in divergence
|
||||
]
|
||||
return (
|
||||
f"> Divergence check at `{path}`: a canon dominates this directory — "
|
||||
f"{'; '.join(parts)}. If this is a new instance, pull that snippet "
|
||||
"and build from it; if it is a deliberate departure, "
|
||||
"`classify_shapes(..., status=\"variant\", reason=…)` records the why; "
|
||||
"otherwise it reads as unintended divergence."
|
||||
)
|
||||
|
||||
|
||||
def _stamp_line(path: str, stamped: list[dict]) -> str:
|
||||
"""One line saying what the ledger just recorded, so the session can
|
||||
correct a wrong stamp in the moment rather than an audit finding it."""
|
||||
|
||||
@@ -30,7 +30,7 @@ from typing import Iterable, NamedTuple
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.code_shape import CodeShape
|
||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,11 +111,25 @@ async def sync_repo_shapes(
|
||||
row.last_seen_commit = seen_marker
|
||||
if signature:
|
||||
row.signature = signature
|
||||
if body_sha:
|
||||
if body_sha and body_sha != row.body_sha:
|
||||
judged = row.status in ("instance", "variant")
|
||||
if judged and not row.classified_sha:
|
||||
# Judged before fingerprints existed: the first sync
|
||||
# that sees a body adopts it as the judged content.
|
||||
row.classified_sha = body_sha
|
||||
elif judged and row.classified_sha != body_sha and row.recheck_at is None:
|
||||
# The body moved under a standing judgment: the judgment
|
||||
# stands, but asks to be confirmed again (#2793).
|
||||
row.recheck_at = now
|
||||
session.add(_event(row, "drifted", now, commit=seen_marker))
|
||||
row.body_sha = body_sha
|
||||
elif row.status in ("instance", "variant") and not row.classified_sha:
|
||||
row.classified_sha = row.body_sha
|
||||
# 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.vanished_at is not None:
|
||||
row.vanished_at = None
|
||||
session.add(_event(row, "reappeared", now, commit=seen_marker))
|
||||
if row.status in _NEEDS_TARGET and row.snippet_id is None:
|
||||
row.status = "unclassified"
|
||||
row.classified_by = None
|
||||
@@ -124,9 +138,50 @@ async def sync_repo_shapes(
|
||||
for key, row in by_key.items():
|
||||
if key not in seen and row.vanished_at is None:
|
||||
row.vanished_at = now
|
||||
session.add(_event(row, "vanished", now, commit=row.last_seen_commit))
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _event(row: CodeShape, event: str, at: datetime, *, commit: str = "") -> CodeShapeEvent:
|
||||
"""A history row for a state change on ``row`` — status/snippet/by/reason
|
||||
are the row's CURRENT values, which for `classified` is the judgment
|
||||
just made and for presence events is the standing one."""
|
||||
return CodeShapeEvent(
|
||||
shape_id=row.id, project_id=row.project_id,
|
||||
path=row.path, symbol=row.symbol, kind=row.kind,
|
||||
event=event, status=row.status, snippet_id=row.snippet_id,
|
||||
classified_by=row.classified_by, reason=row.reason,
|
||||
commit=commit or row.last_seen_commit or "", at=at,
|
||||
)
|
||||
|
||||
|
||||
async def _judge(
|
||||
session, row: CodeShape, *, status: str, snippet_id: int | None,
|
||||
by: str | None, reason: str | None, at: datetime,
|
||||
) -> None:
|
||||
"""Apply a judgment to a row — the ONE place a status is set — and write
|
||||
its history. Clears what a judgment settles: the standing proposal, the
|
||||
recheck ask, the divergence flag; remembers the fingerprint judged.
|
||||
`unclassified` is the withdrawal: fields clear, the examination is
|
||||
forgotten so the proposer looks again, and history records the
|
||||
withdrawal too."""
|
||||
row.status = status
|
||||
row.snippet_id = snippet_id if status in _NEEDS_TARGET else None
|
||||
row.reason = (reason or "").strip() or None
|
||||
row.classified_by = by if status != "unclassified" else None
|
||||
row.classified_at = at if status != "unclassified" else None
|
||||
row.classified_sha = row.body_sha if status != "unclassified" else ""
|
||||
row.recheck_at = None
|
||||
row.diverges_from = None
|
||||
_clear_proposal(row, reexamine=(status == "unclassified"))
|
||||
if row.id is None:
|
||||
# A provisional row (hook stamp on a shape not yet synced): flush so
|
||||
# the event can point at it.
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
session.add(_event(row, "classified", at))
|
||||
|
||||
|
||||
async def mark_canonicals(
|
||||
project_id: int, recorded: list[tuple[int, str, str]]
|
||||
) -> None:
|
||||
@@ -159,19 +214,15 @@ async def mark_canonicals(
|
||||
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
|
||||
await _judge(session, row, status="canonical", snippet_id=covering,
|
||||
by="mechanical", reason=None, 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 _judge(session, row, status="unclassified", snippet_id=None,
|
||||
by=None, reason=None, at=now)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -308,23 +359,11 @@ async def classify_shapes(
|
||||
continue
|
||||
status = item["status"]
|
||||
for row in matches:
|
||||
row.status = status
|
||||
# The machine proposes, judgment classifies: any judgment
|
||||
# retires the standing proposal; a withdrawal also forgets
|
||||
# the examination so the next refresh proposes afresh.
|
||||
_clear_proposal(row, reexamine=(status == "unclassified"))
|
||||
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
|
||||
await _judge(
|
||||
session, row, status=status,
|
||||
snippet_id=int(item["snippet_id"]) if status in _NEEDS_TARGET else None,
|
||||
by=via, reason=item.get("reason"), at=now,
|
||||
)
|
||||
classified += 1
|
||||
await session.commit()
|
||||
return {"classified": classified, "unmatched": unmatched}
|
||||
@@ -341,6 +380,7 @@ async def list_project_shapes(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
proposal: str = "",
|
||||
flag: str = "",
|
||||
) -> tuple[list[CodeShape], int]:
|
||||
"""A filtered page of a project's ledger, with the unfiltered-match total.
|
||||
|
||||
@@ -349,7 +389,9 @@ async def list_project_shapes(
|
||||
beneath it, mirroring recorded-location semantics. ``proposal`` narrows
|
||||
to rows the proposer has spoken about: "any", "canon" (an instance-of-#N
|
||||
suggestion), "derive" (a repeats-with-no-canon group), or one basis
|
||||
name (symbol/reference/text/signature/semantic).
|
||||
name (symbol/reference/text/signature/semantic). ``flag`` narrows to
|
||||
the readout's asks (#2793): "divergence" (new where a canon dominates,
|
||||
`diverges_from` names it) or "recheck" (a judged shape whose body moved).
|
||||
"""
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
@@ -380,6 +422,10 @@ async def list_project_shapes(
|
||||
conds.append(CodeShape.proposal_group.isnot(None))
|
||||
elif proposal:
|
||||
conds.append(CodeShape.proposal_basis == proposal)
|
||||
if flag == "divergence":
|
||||
conds.append(CodeShape.diverges_from.isnot(None))
|
||||
elif flag == "recheck":
|
||||
conds.append(CodeShape.recheck_at.isnot(None))
|
||||
async with async_session() as session:
|
||||
total = (
|
||||
await session.execute(
|
||||
@@ -615,12 +661,8 @@ async def stamp_write_path_instances(
|
||||
by_key[(name, kind)] = row
|
||||
elif not (row.status == "unclassified" or row.classified_by == "hook"):
|
||||
continue # a judgment — or the canon itself — stands
|
||||
row.status = "instance"
|
||||
row.snippet_id = sid
|
||||
row.reason = why
|
||||
row.classified_by = "hook"
|
||||
row.classified_at = now
|
||||
_clear_proposal(row)
|
||||
await _judge(session, row, status="instance", snippet_id=sid, by="hook",
|
||||
reason=why, at=now)
|
||||
stamped.append({
|
||||
"path": path, "symbol": name, "kind": kind,
|
||||
"snippet_id": sid, "reason": why,
|
||||
@@ -1051,15 +1093,224 @@ async def confirm_proposals(
|
||||
for row in rows:
|
||||
if (row.proposal_score or 0.0) < min_score:
|
||||
continue
|
||||
row.status = "instance"
|
||||
row.snippet_id = row.proposed_snippet_id
|
||||
row.reason = (
|
||||
f"confirmed {row.proposal_basis} proposal"
|
||||
f" ({(row.proposal_score or 0.0):.2f})"
|
||||
await _judge(
|
||||
session, row, status="instance", snippet_id=row.proposed_snippet_id,
|
||||
by="agent", at=now,
|
||||
reason=(
|
||||
f"confirmed {row.proposal_basis} proposal"
|
||||
f" ({(row.proposal_score or 0.0):.2f})"
|
||||
),
|
||||
)
|
||||
row.classified_by = "agent"
|
||||
row.classified_at = now
|
||||
_clear_proposal(row)
|
||||
confirmed += 1
|
||||
await session.commit()
|
||||
return {"confirmed": confirmed}
|
||||
|
||||
|
||||
# --- the divergence readout (#2793): button B where button A is canon -------
|
||||
#
|
||||
# Three answers the ledger can now give mechanically:
|
||||
# DIVERGENCE a shape NEW since the previous refresh, in a directory+kind
|
||||
# where one canon dominates the judged siblings, that the
|
||||
# proposer did not match to that canon → `diverges_from=#N`.
|
||||
# Read: "button B appeared where button A is canon — divergence
|
||||
# or variant? classify it." Surfaced in the coverage readout and
|
||||
# in-band at write time (the prior-art hook names the shapes).
|
||||
# HISTORY every judgment / vanish / reappearance / drift is an event;
|
||||
# shape_history answers "what was used here, when, and why".
|
||||
# RECHECK an instance/variant whose body moved since it was judged is
|
||||
# flagged recheck_at (sync) — the judgment stands, re-confirm it.
|
||||
|
||||
# A canon dominates a directory+kind when at least this many siblings are
|
||||
# judged (canonical/instance) and this share of them answer to one snippet.
|
||||
_DENSITY_MIN_JUDGED = 3
|
||||
_DENSITY_SHARE = 0.6
|
||||
|
||||
|
||||
def dominant_canon(rows: Iterable[CodeShape]) -> tuple[int, int, int] | None:
|
||||
"""(snippet_id, its_count, judged_count) when one canon dominates these
|
||||
sibling rows (same directory + kind), else None."""
|
||||
counts: dict[int, int] = {}
|
||||
judged = 0
|
||||
for r in rows:
|
||||
if r.status in ("canonical", "instance") and r.snippet_id is not None:
|
||||
judged += 1
|
||||
counts[r.snippet_id] = counts.get(r.snippet_id, 0) + 1
|
||||
if judged < _DENSITY_MIN_JUDGED or not counts:
|
||||
return None
|
||||
sid, n = max(counts.items(), key=lambda kv: (kv[1], -kv[0]))
|
||||
if n / judged < _DENSITY_SHARE:
|
||||
return None
|
||||
return sid, n, judged
|
||||
|
||||
|
||||
def _dir_of(path: str) -> str:
|
||||
return path.rsplit("/", 1)[0] if "/" in path else ""
|
||||
|
||||
|
||||
async def canon_density(project_id: int, path: str, kind: str) -> tuple[int, int, int] | None:
|
||||
"""The dominant canon for the directory ``path`` sits in, for ``kind`` —
|
||||
the write-time question "is this a canon-dense place?"."""
|
||||
directory = _dir_of(path)
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(CodeShape).where(
|
||||
CodeShape.project_id == project_id,
|
||||
CodeShape.kind == kind,
|
||||
CodeShape.vanished_at.is_(None),
|
||||
CodeShape.path.like(directory + "/%") if directory
|
||||
else CodeShape.path.notlike("%/%"),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
siblings = [r for r in rows if _dir_of(r.path) == directory]
|
||||
return dominant_canon(siblings)
|
||||
|
||||
|
||||
async def write_time_divergence(
|
||||
project_id: int, path: str, shapes: list[tuple[str, str]], stamped: list[dict],
|
||||
) -> list[dict]:
|
||||
"""The in-band check for the shapes the hook named at ``path``: for each
|
||||
kind whose directory has a dominant canon, the named shapes that are
|
||||
not (already or just now) that canon's instance/canonical — new or
|
||||
unclassified rows only; a judged shape is not re-litigated at every
|
||||
edit. Returns [{symbol, kind, canon_snippet_id, instances, judged}]."""
|
||||
just_stamped = {(s["symbol"], s["kind"]): s["snippet_id"] for s in stamped}
|
||||
out: list[dict] = []
|
||||
kinds = {k for k, _n in shapes}
|
||||
density = {k: await canon_density(project_id, path, k) for k in kinds}
|
||||
if not any(density.values()):
|
||||
return out
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(CodeShape).where(
|
||||
CodeShape.project_id == project_id,
|
||||
CodeShape.path == path,
|
||||
CodeShape.vanished_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
by_key = {(r.symbol, r.kind): r for r in rows}
|
||||
for kind, name in shapes:
|
||||
dom = density.get(kind)
|
||||
if not dom:
|
||||
continue
|
||||
sid, n, judged = dom
|
||||
if just_stamped.get((name, kind)) == sid:
|
||||
continue
|
||||
row = by_key.get((name, kind))
|
||||
if row is not None and (
|
||||
row.status != "unclassified" or row.proposed_snippet_id == sid
|
||||
):
|
||||
continue
|
||||
out.append({"symbol": name, "kind": kind, "canon_snippet_id": sid,
|
||||
"instances": n, "judged": judged})
|
||||
return out
|
||||
|
||||
|
||||
async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
|
||||
"""Flag shapes created after ``since`` (the previous refresh) that sit
|
||||
where a canon dominates and were not proposed as that canon. With no
|
||||
previous refresh (first seed) nothing is new, nothing is flagged.
|
||||
Standing flags persist until judged. Returns how many are flagged."""
|
||||
if since is None:
|
||||
return 0
|
||||
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_dir: dict[tuple[str, str], list[CodeShape]] = {}
|
||||
for r in rows:
|
||||
by_dir.setdefault((_dir_of(r.path), r.kind), []).append(r)
|
||||
flagged = 0
|
||||
for siblings in by_dir.values():
|
||||
dom = dominant_canon(siblings)
|
||||
for r in siblings:
|
||||
if r.status != "unclassified":
|
||||
continue
|
||||
if r.diverges_from is not None:
|
||||
flagged += 1
|
||||
continue
|
||||
if dom is None or r.created_at is None or r.created_at <= since:
|
||||
continue
|
||||
if r.proposed_snippet_id == dom[0]:
|
||||
continue # the proposer already says "instance of the canon"
|
||||
r.diverges_from = dom[0]
|
||||
flagged += 1
|
||||
await session.commit()
|
||||
return flagged
|
||||
|
||||
|
||||
def divergence_summary(rows: Iterable[CodeShape], *, top: int = 10) -> dict:
|
||||
"""Readout view: flagged shapes (newest first) and the recheck count."""
|
||||
flagged = [r for r in rows if r.diverges_from is not None and r.status == "unclassified"]
|
||||
flagged.sort(key=lambda r: (r.created_at or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
|
||||
recheck = sum(1 for r in rows if r.recheck_at is not None and r.vanished_at is None)
|
||||
return {
|
||||
"divergent": len(flagged),
|
||||
"divergence": [
|
||||
{"path": r.path, "symbol": r.symbol, "kind": r.kind,
|
||||
"canon_snippet_id": r.diverges_from}
|
||||
for r in flagged[:top]
|
||||
],
|
||||
"recheck": recheck,
|
||||
}
|
||||
|
||||
|
||||
async def shape_history(
|
||||
user_id: int, project_id: int, path: str, *, symbol: str = "", limit: int = 200
|
||||
) -> dict:
|
||||
"""What was used at ``path`` (a file or directory), when, and why: the
|
||||
current rows plus their events, oldest first. Read-gated like every
|
||||
other ledger read; {} when the caller cannot read the project."""
|
||||
from sqlalchemy import or_
|
||||
|
||||
from scribe.services import access
|
||||
|
||||
if not await access.can_read_project(user_id, project_id):
|
||||
return {}
|
||||
clean = (path or "").strip().strip("/")
|
||||
conds = [CodeShape.project_id == project_id]
|
||||
if clean:
|
||||
conds.append(or_(CodeShape.path == clean, CodeShape.path.like(clean + "/%")))
|
||||
if symbol.strip():
|
||||
conds.append(CodeShape.symbol == symbol.strip())
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(CodeShape).where(*conds)
|
||||
.order_by(CodeShape.path, CodeShape.symbol, CodeShape.kind)
|
||||
.limit(500)
|
||||
)
|
||||
).scalars().all()
|
||||
ids = [r.id for r in rows]
|
||||
events = (
|
||||
await session.execute(
|
||||
select(CodeShapeEvent).where(CodeShapeEvent.shape_id.in_(ids))
|
||||
.order_by(CodeShapeEvent.at.asc(), CodeShapeEvent.id.asc())
|
||||
.limit(max(1, min(limit, 1000)))
|
||||
)
|
||||
).scalars().all() if ids else []
|
||||
return {
|
||||
"shapes": [
|
||||
{
|
||||
"path": r.path, "symbol": r.symbol, "kind": r.kind,
|
||||
"status": r.status, "snippet_id": r.snippet_id,
|
||||
"classified_by": r.classified_by, "reason": r.reason,
|
||||
"first_seen_commit": r.first_seen_commit,
|
||||
"last_seen_commit": r.last_seen_commit,
|
||||
"first_seen_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"vanished_at": r.vanished_at.isoformat() if r.vanished_at else None,
|
||||
"recheck_at": r.recheck_at.isoformat() if r.recheck_at else None,
|
||||
"diverges_from": r.diverges_from,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"events": [e.to_dict() for e in events],
|
||||
}
|
||||
|
||||
@@ -493,3 +493,130 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
|
||||
await apply_derive_groups(pid)
|
||||
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
|
||||
assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor
|
||||
|
||||
|
||||
# --- #2793: the divergence readout against real rows -------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_second_confirm_dialog_is_detected_and_named(seeded):
|
||||
"""The milestone's acceptance case. A directory where one canon dominates
|
||||
the judged siblings (a confirm helper with four instance call sites);
|
||||
after a previous refresh, a new shape lands there that the proposer does
|
||||
not match to the canon — it is flagged `diverges_from` the canon, the
|
||||
readout names it, and the in-band check names it at write time. A
|
||||
judgment clears the flag; a shape proposed AS the canon is not flagged."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.services.shape_ledger import (
|
||||
divergence_summary, flag_divergence, live_rows, propose_for_repo,
|
||||
write_time_divergence,
|
||||
)
|
||||
|
||||
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
|
||||
comp = "frontend/src/components"
|
||||
base = _defs(
|
||||
*[(f"{comp}/{n}.vue", "sym", f"on{n}", f"async function on{n}() {{",
|
||||
f"async function on{n}() {{\n const ok = await factory();\n if (!ok) return;\n}}")
|
||||
for n in ("Trash", "Delete", "Remove", "Restore")],
|
||||
)
|
||||
await sync_repo_shapes(pid, REPO, base, seen_marker="aaa111")
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": f"{comp}/{n}.vue", "symbol": f"on{n}", "status": "instance", "snippet_id": sid}
|
||||
for n in ("Trash", "Delete", "Remove", "Restore")
|
||||
], via="audit")
|
||||
previous = datetime.now(timezone.utc)
|
||||
|
||||
# Button B: a hand-rolled confirm that never touches the canon, plus a
|
||||
# proper new instance (references the canon → the proposer claims it).
|
||||
later = base + _defs(
|
||||
(f"{comp}/Danger.vue", "sym", "confirmDanger", "function confirmDanger() {",
|
||||
"function confirmDanger() {\n return window.confirm('Really?');\n}"),
|
||||
(f"{comp}/Proper.vue", "sym", "onPurge", "async function onPurge() {",
|
||||
"async function onPurge() {\n const ok = await factory();\n if (!ok) return;\n}"),
|
||||
)
|
||||
await sync_repo_shapes(pid, REPO, later, seen_marker="bbb222")
|
||||
with _quiet_semantic():
|
||||
await propose_for_repo(owner, pid, REPO, later)
|
||||
assert await flag_divergence(pid, since=None) == 0 # a first seed flags nothing
|
||||
assert await flag_divergence(pid, since=previous - timedelta(seconds=1)) == 1
|
||||
|
||||
rows, total = await list_project_shapes(owner, pid, flag="divergence")
|
||||
assert total == 1
|
||||
assert rows[0].symbol == "confirmDanger" and rows[0].diverges_from == sid
|
||||
summary = divergence_summary(await live_rows(pid))
|
||||
assert summary["divergent"] == 1
|
||||
assert summary["divergence"][0]["symbol"] == "confirmDanger"
|
||||
assert summary["divergence"][0]["canon_snippet_id"] == sid
|
||||
|
||||
# In-band: the hook names the shape at write time → the check names the canon.
|
||||
named = await write_time_divergence(
|
||||
pid, f"{comp}/Danger.vue", [("sym", "confirmDanger")], stamped=[]
|
||||
)
|
||||
assert named == [{"symbol": "confirmDanger", "kind": "sym", "canon_snippet_id": sid,
|
||||
"instances": 4, "judged": 4}]
|
||||
# ...but an already-judged shape, or one just stamped as the canon's
|
||||
# instance, is not re-litigated.
|
||||
assert await write_time_divergence(pid, f"{comp}/Trash.vue", [("sym", "onTrash")], stamped=[]) == []
|
||||
assert await write_time_divergence(
|
||||
pid, f"{comp}/New.vue", [("sym", "onNew")],
|
||||
stamped=[{"symbol": "onNew", "kind": "sym", "snippet_id": sid}],
|
||||
) == []
|
||||
# A directory with no dominant canon is silent.
|
||||
assert await write_time_divergence(pid, "src/other.py", [("sym", "thing")], stamped=[]) == []
|
||||
|
||||
# The judgment answers the question and clears the flag.
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": f"{comp}/Danger.vue", "symbol": "confirmDanger", "status": "variant",
|
||||
"snippet_id": sid, "reason": "native confirm is fine in the dev-only panel"},
|
||||
])
|
||||
rows, total = await list_project_shapes(owner, pid, flag="divergence")
|
||||
assert total == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(seeded):
|
||||
from scribe.services.shape_ledger import shape_history
|
||||
|
||||
owner, other, pid, sid = (
|
||||
seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
|
||||
)
|
||||
v1 = _defs(("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n return factory()"))
|
||||
await sync_repo_shapes(pid, REPO, v1, seen_marker="c1")
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "src/app.py", "symbol": "make_app", "status": "instance", "snippet_id": sid},
|
||||
])
|
||||
# The body moves under the judgment → drifted + recheck; re-judging clears it.
|
||||
v2 = _defs(("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n return factory(debug=True)"))
|
||||
await sync_repo_shapes(pid, REPO, v2, seen_marker="c2")
|
||||
rows, total = await list_project_shapes(owner, pid, flag="recheck")
|
||||
assert total == 1 and rows[0].symbol == "make_app" and rows[0].status == "instance"
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "src/app.py", "symbol": "make_app", "status": "variant", "snippet_id": sid,
|
||||
"reason": "debug flag is deliberate here"},
|
||||
])
|
||||
rows, total = await list_project_shapes(owner, pid, flag="recheck")
|
||||
assert total == 0
|
||||
# Then it vanishes from the tree.
|
||||
await sync_repo_shapes(pid, REPO, [], seen_marker="c3")
|
||||
|
||||
history = await shape_history(owner, pid, "src/app.py", symbol="make_app")
|
||||
shape = history["shapes"][0]
|
||||
assert shape["status"] == "variant" and shape["vanished_at"] is not None
|
||||
# The seeded fixture synced this row first (marker "main"); v1/v2 are
|
||||
# later sightings — first_seen keeps the first.
|
||||
assert shape["first_seen_commit"] == "main" and shape["last_seen_commit"] == "c2"
|
||||
timeline = [(e["event"], e["status"], e["snippet_id"], e["commit"]) for e in history["events"]]
|
||||
assert timeline == [
|
||||
("classified", "instance", sid, "c1"),
|
||||
("drifted", "instance", sid, "c2"),
|
||||
("classified", "variant", sid, "c2"),
|
||||
("vanished", "variant", sid, "c2"),
|
||||
]
|
||||
assert history["events"][2]["reason"] == "debug flag is deliberate here"
|
||||
assert history["events"][0]["classified_by"] == "agent"
|
||||
# Directory-wide read works (the empty sync also vanished the seeded
|
||||
# Config and helper rows under src/ — two more events); an outsider
|
||||
# reads nothing.
|
||||
assert len((await shape_history(owner, pid, "src"))["events"]) == 6
|
||||
assert await shape_history(other, pid, "src/app.py") == {}
|
||||
|
||||
@@ -507,3 +507,19 @@ def test_coverage_line_names_the_proposers_standing():
|
||||
assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line
|
||||
line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]})
|
||||
assert "(1 derive group)" in line
|
||||
|
||||
|
||||
def test_coverage_line_names_divergence_and_recheck():
|
||||
from scribe.services.coverage import coverage_line
|
||||
|
||||
base = {
|
||||
"total": 100, "accounted": 40, "unclassified": 60,
|
||||
"counts": {"canonical": 10, "instance": 30, "variant": 0, "exempt": 0},
|
||||
"computed_at": "2026-08-21T00:00:00+00:00",
|
||||
"largest_gaps": [{"dir": "src", "unclassified": 60, "total": 60}],
|
||||
}
|
||||
line = coverage_line({**base, "divergent": 2, "recheck": 1, "proposed": 5})
|
||||
assert "; 60 unclassified (5 proposed, 2 DIVERGENT), largest: src" in line
|
||||
assert line.endswith("; 1 judged shape changed since judged — recheck")
|
||||
assert "DIVERGENT" not in coverage_line(base)
|
||||
assert "recheck" not in coverage_line(base)
|
||||
|
||||
@@ -13,11 +13,12 @@ import pytest
|
||||
from scribe.services import backup
|
||||
|
||||
|
||||
def test_backup_version_is_v7():
|
||||
"""v7 added code_shapes (#2787). The bump is the point of the test —
|
||||
a payload section added without moving the version produces backups that
|
||||
are structurally different and indistinguishable by inspection."""
|
||||
assert backup.BACKUP_VERSION == 7
|
||||
def test_backup_version_is_v8():
|
||||
"""v7 added code_shapes (#2787), v8 its history (#2793). The bump is the
|
||||
point of the test — a payload section added without moving the version
|
||||
produces backups that are structurally different and indistinguishable
|
||||
by inspection."""
|
||||
assert backup.BACKUP_VERSION == 8
|
||||
|
||||
|
||||
def test_not_included_lists_the_known_gaps():
|
||||
@@ -114,7 +115,7 @@ async def test_export_full_backup_contains_every_declared_section():
|
||||
"topic_suppressions",
|
||||
"systems", "record_systems", "design_systems",
|
||||
"design_tokens", "note_usage_events", "repo_bindings",
|
||||
"note_supersessions", "code_shapes"):
|
||||
"note_supersessions", "code_shapes", "code_shape_events"):
|
||||
assert key in out, f"missing export section: {key}"
|
||||
assert out[key] == []
|
||||
|
||||
|
||||
@@ -291,3 +291,54 @@ def test_proposer_tools_are_mounted():
|
||||
assert mcp._tool_manager.get_tool("confirm_shape_proposals") is not None
|
||||
tool = mcp._tool_manager.get_tool("list_shapes")
|
||||
assert "proposal" in tool.parameters.get("properties", {})
|
||||
|
||||
|
||||
# --- step 7: the divergence readout (pure) ----------------------------------
|
||||
|
||||
|
||||
def _row(path, kind="sym", status="unclassified", snippet_id=None):
|
||||
r = CodeShape(project_id=1, repo_key="r", path=path, symbol=path.rsplit("/", 1)[-1], kind=kind)
|
||||
r.status, r.snippet_id = status, snippet_id
|
||||
return r
|
||||
|
||||
|
||||
def test_dominant_canon_needs_enough_judged_siblings_and_a_clear_majority():
|
||||
from scribe.services.shape_ledger import dominant_canon
|
||||
|
||||
dense = [_row(f"c/{i}", status="instance", snippet_id=7) for i in range(4)] + [
|
||||
_row("c/x", status="instance", snippet_id=8), _row("c/y")]
|
||||
assert dominant_canon(dense) == (7, 4, 5)
|
||||
sparse = [_row("c/a", status="instance", snippet_id=7), _row("c/b", status="instance", snippet_id=7)]
|
||||
assert dominant_canon(sparse) is None # 2 judged < floor
|
||||
split = [_row(f"c/{i}", status="instance", snippet_id=7) for i in range(2)] + [
|
||||
_row(f"c/{i+5}", status="instance", snippet_id=8) for i in range(2)]
|
||||
assert dominant_canon(split) is None # 50% < 60% share
|
||||
# Variants are departures, not votes; canonical counts like an instance.
|
||||
mixed = [_row("c/a", status="canonical", snippet_id=7)] + [
|
||||
_row(f"c/{i}", status="instance", snippet_id=7) for i in range(2)] + [
|
||||
_row("c/v", status="variant", snippet_id=9)]
|
||||
assert dominant_canon(mixed) == (7, 3, 3)
|
||||
|
||||
|
||||
def test_history_and_readout_tools_are_mounted_and_shape_history_is_read_only():
|
||||
from scribe.mcp.server import _READ_ONLY_TOOLS, build_mcp_server
|
||||
|
||||
mcp = build_mcp_server()
|
||||
assert mcp._tool_manager.get_tool("shape_history") is not None
|
||||
assert "shape_history" in _READ_ONLY_TOOLS
|
||||
assert "flag" in mcp._tool_manager.get_tool("list_shapes").parameters.get("properties", {})
|
||||
|
||||
|
||||
def test_history_and_divergence_columns_are_pinned():
|
||||
from scribe.models.code_shape import SHAPE_EVENTS, CodeShapeEvent
|
||||
|
||||
cols = CodeShape.__table__.c
|
||||
for name in ("classified_sha", "recheck_at", "diverges_from"):
|
||||
assert name in cols, name
|
||||
assert "ix_code_shapes_diverges" in {ix.name for ix in CodeShape.__table__.indexes}
|
||||
ev = CodeShapeEvent.__table__
|
||||
fk = next(iter(ev.c.shape_id.foreign_keys))
|
||||
assert fk.ondelete == "CASCADE" and fk.column.table.name == "code_shapes"
|
||||
assert not ev.c.snippet_id.foreign_keys # history outlives the snippet
|
||||
assert SHAPE_EVENTS == ("classified", "vanished", "reappeared", "drifted")
|
||||
assert "code_shape_events" in Base.metadata.tables
|
||||
|
||||
@@ -1248,3 +1248,42 @@ def test_hook_sends_the_enclosing_definition_for_a_body_edit(tmp_path):
|
||||
},
|
||||
})
|
||||
assert seen["shapes"] == ["sym:onTrash"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_time_divergence_check_is_named_in_band():
|
||||
"""#2793: the hook named a shape at a path whose directory a canon
|
||||
dominates, and the stamp didn't make it that canon's instance — the hint
|
||||
must say so at the write, even when nothing else renders."""
|
||||
from scribe.services import plugin_context as pc
|
||||
div = [{"symbol": "confirmDanger", "kind": "sym", "canon_snippet_id": 2761,
|
||||
"instances": 20, "judged": 21}]
|
||||
check = AsyncMock(return_value=div)
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_divergence", check):
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "frontend/src/components/Danger.vue", code=REAL_CODE, project_id=24,
|
||||
stamp_shapes=[("sym", "confirmDanger")],
|
||||
)
|
||||
check.assert_awaited_once_with(24, "frontend/src/components/Danger.vue",
|
||||
[("sym", "confirmDanger")], [])
|
||||
assert out["divergence"] == div
|
||||
assert "Divergence check at `frontend/src/components/Danger.vue`" in out["context"]
|
||||
assert "`confirmDanger` → #2761 (20 of 21 judged siblings are its instances)" in out["context"]
|
||||
assert "variant" in out["context"]
|
||||
# No project → no check at all.
|
||||
check.reset_mock()
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_divergence", check):
|
||||
out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, stamp_shapes=[("sym", "f")])
|
||||
check.assert_not_awaited()
|
||||
assert out["divergence"] == []
|
||||
|
||||
Reference in New Issue
Block a user