diff --git a/alembic/versions/0081_shape_history_and_divergence.py b/alembic/versions/0081_shape_history_and_divergence.py new file mode 100644 index 0000000..052f883 --- /dev/null +++ b/alembic/versions/0081_shape_history_and_divergence.py @@ -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) diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 1e54742..6b7d7df 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -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(null); @@ -775,6 +786,28 @@ async function confirmDelete() { {{ g.label }} ×{{ g.size }} +
+ Divergence: + + {{ d.kind === 'css' ? '.' : '' }}{{ d.symbol }} + → #{{ d.canon_snippet_id }} + + + +{{ (coverage.divergent || 0) - (coverage.divergence?.length || 0) }} more + + + {{ coverage.recheck }} to recheck + +

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; diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f01ae03..b2ea759 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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": { diff --git a/plugin/skills/shape-accounting/SKILL.md b/plugin/skills/shape-accounting/SKILL.md index 6bdbfe1..2fb419e 100644 --- a/plugin/skills/shape-accounting/SKILL.md +++ b/plugin/skills/shape-accounting/SKILL.md @@ -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 , 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, diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index f0ee82c..149d12c 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -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 diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index f86d04b..6fbebdd 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -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 , + re-judged variant of #M at because , vanished at ". + 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) diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index ae7309c..56ec114 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -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 diff --git a/src/scribe/models/code_shape.py b/src/scribe/models/code_shape.py index 6775d5b..7cb1967 100644 --- a/src/scribe/models/code_shape.py +++ b/src/scribe/models/code_shape.py @@ -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 , #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(), + } diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index cd1c7ae..5929c23 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -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) diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index 8cbbb43..2b9217a 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -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 diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 6b7a29a..b31afb7 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -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.""" diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index 49fef0c..f0ccdcf 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -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], + } diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index 5a5aa42..0667697 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -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") == {} diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index 8220527..3e40f4c 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.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) diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index 6978d87..a1e19e7 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -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] == [] diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index 9c333bc..6e062d9 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -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 diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index 4462803..fbbdc0d 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -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"] == []