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

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:
2026-08-20 23:13:39 -04:00
co-authored by Claude Fable 5
parent 386b27e422
commit d74a244b3a
17 changed files with 880 additions and 62 deletions
@@ -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)
+34
View File
@@ -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 -1
View File
@@ -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": {
+19
View File
@@ -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,
+1 -1
View File
@@ -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
+36 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+77
View File
@@ -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(),
}
+53 -7
View File
@@ -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)
+22
View File
@@ -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
+32 -1
View File
@@ -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."""
+294 -43
View File
@@ -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],
}
+127
View File
@@ -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") == {}
+16
View File
@@ -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)
+7 -6
View File
@@ -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] == []
+51
View File
@@ -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
+39
View File
@@ -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"] == []