Merge pull request 'Shape ledger steps 1-4 + direct-minting Systems bootstrap (milestone 294, #2798)' (#115) from dev into main
CI & Build / TypeScript typecheck (push) Successful in 50s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m14s
CI & Build / Build & push image (push) Successful in 16s

This commit was merged in pull request #115.
This commit is contained in:
2026-08-19 21:24:40 -04:00
22 changed files with 1387 additions and 167 deletions
@@ -0,0 +1,66 @@
"""The shape ledger: code_shapes (#2787, milestone 294)
Revision ID: 0079
Revises: 0078
Create Date: 2026-08-19
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small); this table accounts for EVERY shape the
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
from the coverage sync (step 2) and gain judgments — canonical / instance /
variant / exempt — from audits, hooks, and the mechanical proposer.
Unclassified IS the todo list.
"""
import sqlalchemy as sa
from alembic import op
revision = "0079"
down_revision = "0078"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shapes",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"project_id",
sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("repo_key", sa.Text(), 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("status", sa.Text(), nullable=False, server_default="unclassified"),
sa.Column(
"snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
)
op.create_index(
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
)
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
op.drop_table("code_shapes")
+24 -12
View File
@@ -424,15 +424,17 @@ async function loadNotes() {
interface CoverageGap {
dir: string;
uncovered: number;
unclassified: number;
total: number;
}
interface Coverage {
total: number;
recorded: number;
accounted: number;
unclassified: number;
counts: Record<string, number>;
estimate: boolean;
computed_at: string;
repos: { repo: string; ref: string; total: number; recorded: number }[];
repos: { repo: string; ref: string; total: number; accounted: number }[];
largest_gaps: CoverageGap[];
}
@@ -712,32 +714,42 @@ async function confirmDelete() {
</div>
<template v-if="coverage">
<div class="coverage-numbers">
<span class="coverage-count">{{ coverage.recorded }}/{{ coverage.total }}</span>
<span class="coverage-label">shapes recorded</span>
<span class="coverage-count">{{ coverage.accounted }}/{{ coverage.total }}</span>
<span class="coverage-label">shapes accounted for</span>
</div>
<div
class="coverage-bar"
role="progressbar"
:aria-valuenow="coverage.recorded"
:aria-valuenow="coverage.accounted"
:aria-valuemin="0"
:aria-valuemax="coverage.total"
aria-label="Shapes with a recorded snippet"
aria-label="Shapes classified against canon"
>
<div
class="coverage-bar-fill"
:style="{ width: (coverage.total ? (coverage.recorded / coverage.total) * 100 : 0) + '%' }"
:style="{ width: (coverage.total ? (coverage.accounted / coverage.total) * 100 : 0) + '%' }"
></div>
</div>
<div v-if="coverage.counts" class="coverage-gaps">
<span
v-for="k in ['canonical', 'instance', 'variant', 'exempt']"
:key="k"
>
<span v-if="coverage.counts[k]" class="coverage-gap-chip">
{{ coverage.counts[k] }} {{ k }}
</span>
</span>
</div>
<div v-if="coverage.largest_gaps?.length" class="coverage-gaps">
<span class="coverage-gaps-label">Largest gaps:</span>
<span class="coverage-gaps-label">Most unclassified:</span>
<span v-for="gap in coverage.largest_gaps" :key="gap.dir" class="coverage-gap-chip">
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.uncovered }}</span>
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.unclassified }}</span>
</span>
</div>
</template>
<p v-else class="coverage-empty">
Not measured yet Refresh compares the bound repo's definitions
against recorded snippets.
Not measured yet Refresh reads the bound repo's definitions into
the shape ledger and reports how many are classified against canon.
</p>
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
<!-- Forge pin (#2778): owner-only, because the eligible set is the
+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.31",
"version": "0.1.32",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+15
View File
@@ -107,6 +107,21 @@ The result is a single entry that shows every place the thing is used — which
exactly the signal that it was worth consolidating. This is the cure the create
gate only hints at when it blocks a near-duplicate.
## Consumer maps are rows, never prose
Every enumerated relationship between code and canon belongs in the shape
ledger, not in a sentence. When you establish that call sites route through a
canonical helper — during an audit, a verify pass, or a consolidation —
record each consuming definition with
`classify_shapes(project_id, [{path, symbol, status: "instance", snippet_id}])`.
A deliberate departure is a `"variant"` (reason required — the why IS the
record); a judged one-off is `"exempt"` (reason required). Prose in a
verification detail cannot be sorted, queried, or diffed; rows are what make
"what uses this?" answerable forever. `list_shapes(project_id,
status="unclassified")` is the standing todo — and N same-shaped occurrences
matching no canon means derive one first (consolidate, `create_snippet`,
then classify the rest against it), never N loose classifications.
## Why this pays off
A one-off written a second time is the cost this avoids — and at project
+8 -5
View File
@@ -49,15 +49,15 @@ Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
- WHERE work happens: Systems. Tag records with system_ids as you write;
create_system when the area is unmodelled.
- HOW to work: rules are pull-only and binding — call list_always_on_rules()
yourself at session start; a push that also delivered them was an
optimisation, not the bridge.
yourself at session start.
- UI: the project's design system is binding — resolve_design_system /
get_design_system_stylesheet before hand-writing a value.
- REUSE: search snippets before writing a helper; record what you build with
create_snippet. Saved procedures are Processes (follow verbatim). Deletes
are trash-recoverable.
create_snippet; classify shapes against canon (classify_shapes) — a
consumer map is rows, never prose. Saved procedures are Processes (follow
verbatim). Deletes are trash-recoverable.
A task is a note with status; *_note tools for notes, *_task for tasks.
A task is a note with status (*_note vs *_task tools).
Creates are duplicate-gated: a near-match BLOCKS and returns the existing
id — update it, don't force. shared:true records are another user's — a
suggestion, not the operator's settled practice.
@@ -110,6 +110,9 @@ _READ_ONLY_TOOLS = frozenset({
# Which repos map to which project. Read-only by nature; bind_repo /
# unbind_repo are the writes.
"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",
})
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
+3 -2
View File
@@ -5,8 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
systems, tags, tasks, trash,
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, shapes,
snippets, systems, tags, tasks, trash,
)
@@ -24,5 +24,6 @@ def register_all(mcp) -> None:
repos.register(mcp)
processes.register(mcp)
snippets.register(mcp)
shapes.register(mcp)
rulebooks.register(mcp)
trash.register(mcp)
+13 -10
View File
@@ -61,11 +61,14 @@ async def enter_project(project_id: int) -> dict:
open_tasks, recent_notes, design_system, systems, pattern_coverage —
plus systems_bootstrap, present only when it applies (see below).
`pattern_coverage` (usually null) is a one-line estimate of how much of
the bound repo's code has recorded snippets — e.g. "pattern-library
coverage: 34/210 shapes recorded (estimate); largest gaps: internal/api".
When present, treat the gaps as a standing invitation: as you touch code
in those areas, record the shapes you find with create_snippet.
`pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
unclassified, largest: internal/api". Unclassified IS the todo: as you
touch code in those areas, classify the shapes you can (instances of
recorded canon, deliberate variants, one-off exemptions) and record the
canon that's missing with create_snippet.
`systems` is the project's vocabulary of named subsystems/areas. It is
returned here so you can TAG as you write: when creating or meaningfully
@@ -75,10 +78,10 @@ async def enter_project(project_id: int) -> dict:
a subsystem's accumulated records with list_system_records.
`systems_bootstrap` appears ONLY when the project has many records and no
Systems at all — act on it before starting other work: propose a starter
vocabulary from the areas the project's records name, confirm it with the
operator, and create_system the confirmed set. It stops appearing the
moment the first System exists.
Systems at all — act on it before starting other work: create_system a
starter vocabulary from the areas the project's records name, directly
and without asking permission, preferring the standard names the ask
lists. It stops appearing the moment the first System exists.
`design_system` is null unless the project points at one. When present it
carries the chain-merged guidance (the house style AND this project's
@@ -126,7 +129,7 @@ async def enter_project(project_id: int) -> dict:
# The arrival-moment half of the bootstrap ask (#2683): session start is
# when the agent has just read the project map and is not yet deep in a
# task — the one moment "propose a starter vocabulary" is cheap. The
# task — the one moment minting a starter vocabulary is cheap. The
# write-moment half rides untagged-record responses (attach_systems);
# both retire the instant the first System exists.
systems_bootstrap = None
+95
View File
@@ -0,0 +1,95 @@
"""Shape-ledger MCP tools — the classification write/read surface (#2789).
The accounting model (note 2786): the snippet library records CANON (small);
the ledger accounts for EVERY extracted shape (total). These tools are how
agents move shapes out of `unclassified` — the todo state — and how they read
what still needs judgment. The ledger rows themselves are fed by the coverage
refresh; these tools only ever judge what the sync has seen.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import shape_ledger as shape_ledger_svc
async def classify_shapes(
project_id: int, classifications: list[dict], via: str = "agent"
) -> dict:
"""Record judgments for a project's code shapes — in batch, as rows.
EVERY shape in a bound repo should end up classified (note 2786):
- `instance` of snippet N — it conforms to recorded canon (family-level
canon in another project counts; that fully accounts for the shape).
- `variant` of snippet N — a deliberate, named departure. `reason`
(the why) is REQUIRED; it is the record.
- `exempt` — judged genuinely one-off. `reason` REQUIRED.
- `canonical` of snippet N — this row IS the snippet's reference
(rarely set by hand; the coverage sync stamps these mechanically).
- `unclassified` — withdraw a judgment; the shape rejoins the todo.
Consumer maps belong HERE, not in prose: when an audit enumerates call
sites of a canonical helper, each call site's defining shape is an
`instance` row — a sentence in a verification detail cannot be sorted,
queried, or diffed.
Args:
project_id: The project whose ledger is being judged.
classifications: Objects of {path, symbol, status, kind?, snippet_id?,
reason?}. path+symbol name the shape exactly as list_shapes shows
it; kind ("sym"/"css") narrows when one file defines both.
snippet_id is required for canonical/instance/variant; reason is
required for variant/exempt.
via: Who is judging — "agent" (default), "audit" (a sweep), or
"import" (carrying maps recorded elsewhere).
All-or-nothing: a structural error, a missing snippet target, or no write
access applies NOTHING. Returns {"classified": N, "unmatched": [...]} —
unmatched names shapes no live ledger row matches (the tree may have
moved since you listed; re-run the project's coverage refresh to re-sync).
"""
uid = current_user_id()
return await shape_ledger_svc.classify_shapes(
uid, project_id, classifications, via=via
)
async def list_shapes(
project_id: int,
status: str = "",
path: str = "",
snippet_id: int = 0,
include_vanished: bool = False,
limit: int = 100,
offset: int = 0,
) -> dict:
"""Read a project's shape ledger — `status="unclassified"` IS the todo.
Every extracted definition in the project's bound repos has a row here
(fed by the coverage refresh). Filters compose:
Args:
status: canonical | instance | variant | exempt | unclassified.
path: exact file, or a directory — matches everything beneath it
(the coverage line's "largest" dirs go straight in here).
snippet_id: rows classified against this snippet — a consumer map.
include_vanished: include shapes no longer in the tree (history).
limit/offset: page through big ledgers (limit caps at 500).
Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. Classify what you can judge with classify_shapes; a
repeating shape with NO recorded canon is a derive-one-first moment
(consolidate onto a reference, create_snippet it, then classify the
rest against it), never N loose classifications.
"""
uid = current_user_id()
rows, total = await shape_ledger_svc.list_project_shapes(
uid, project_id,
status=status, path=path, snippet_id=snippet_id,
include_vanished=include_vanished, limit=limit, offset=offset,
)
return {"shapes": [r.to_dict() for r in rows], "total": total}
def register(mcp) -> None:
for fn in (classify_shapes, list_shapes):
mcp.tool(name=fn.__name__)(fn)
+23
View File
@@ -207,6 +207,12 @@ async def get_snippet(snippet_id: int) -> dict:
the source moved on — trust the location over the cached body and
consider verify_snippet after you look.
When the shape ledger has judgments against this snippet, the response
carries `instances` (shapes classified as conforming to it — the
structured consumer map) and/or `variants` (named departures, each with
its why). Consult them before changing the snippet's contract: they are
the call sites your change lands on (classify_shapes maintains them).
If the record belongs to someone else it carries `shared: true` with the
`owner` and your `permission`. Read that as ONE PERSON'S SUGGESTION, not as
established practice here: judge it on its merits, say whose it is when you
@@ -229,6 +235,16 @@ async def get_snippet(snippet_id: int) -> dict:
await systems_tools.attach_systems(
uid, note.user_id, data, note.id, note.project_id
)
# The structured consumer map (#2789): ledger rows judged against this
# snippet. Attached only when non-empty (#2483) — and never for projects
# the caller can't read.
from scribe.services import shape_ledger as shape_ledger_svc
consumers = await shape_ledger_svc.snippet_consumers(uid, int(note.id))
if consumers["instances"]:
data["instances"] = consumers["instances"]
if consumers["variants"]:
data["variants"] = consumers["variants"]
return data
@@ -336,6 +352,13 @@ async def verify_snippet(
"moved to services/knowledge.py"). It's what makes the record fixable later
by someone who wasn't here, so write it for them, not as a status echo.
CONSUMERS you enumerate while checking ("all N call sites still route
through it") are ledger rows, not detail prose: classify each consuming
definition as an `instance` of this snippet with classify_shapes — prose
cannot be sorted, queried, or diffed (note 2786's lesson), and the rows
are what make "what uses this?" answerable forever. `detail` keeps the
WHY and what changed, nothing that belongs in a row.
A verdict expires automatically if the snippet is edited afterwards: it is
stamped with a hash of the code it was checked against, so it can never go
on vouching for code nobody checked. Re-verify after fixing a record.
+35 -13
View File
@@ -23,6 +23,18 @@ from scribe.services import systems as systems_svc
_BOOTSTRAP_MIN_RECORDS = 20
_BOOTSTRAP_TITLES = 6
# The standard vocabulary (#2798): area names that recur across software
# projects, offered so "CI & Release" means the same thing in every project
# on the instance. Consistency comes from the shared names — NOT from asking
# the operator to approve each System; agents mint directly. Generic by
# design (rule #115): archetypes any codebase could have, never one
# install's subsystems. Mint freely beyond the list; the duplicate gate
# guards sprawl.
_STANDARD_SYSTEMS = (
"CI & Release", "Auth & Access", "Data Model & Storage", "API Surface",
"UI & Design", "Import & Export", "Background Jobs", "Observability",
)
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
"""The escalated vocabulary-bootstrap ask for a mature zero-Systems project.
@@ -33,8 +45,10 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
(#2683). What separates the nudges that convert from the prose that
doesn't (the duplicate gate, the prior-art "already defined in 2 files")
is the project's OWN evidence in the ask — so this one carries the record
count and the recent titles, and asks for a concrete deliverable: propose
a starter set, confirm, create.
count and the recent titles, and asks for a concrete deliverable: create
a starter set directly, preferring the standard cross-project names.
Deliberately NOT an approval flow (#2798): the operator is not a
permission gate for vocabulary; the standard names carry consistency.
Self-retiring by construction: callers only reach for it while the
project has zero Systems, so the first create_system ends it everywhere.
@@ -56,12 +70,15 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
return (
f"This project has {total} records and NO Systems modelled — none of "
"them can be tagged to an area, so recurring problem-spots stay "
"invisible. Bootstrap the vocabulary now, in this session: from the "
f"areas the records themselves name (recent: {titles}), propose 3-6 "
"Systems to the operator, create_system each confirmed one with a "
"one-paragraph charter, then tag this record (system_ids=[...]). "
"This ask repeats until the first System exists; answering it once "
"retires it for every future record."
"invisible. Bootstrap the vocabulary now, in this session, without "
"asking permission — creating Systems is your call, not an approval "
f"flow. From the areas the records themselves name (recent: {titles}), "
"create_system 3-6 Systems, each with a one-paragraph charter, then "
"tag this record (system_ids=[...]). Where an area fits a standard "
f"name, use it verbatim so it means the same thing in every project: "
f"{', '.join(_STANDARD_SYSTEMS)}. Mint freely beyond that list — the "
"duplicate gate guards sprawl. This ask repeats until the first "
"System exists; answering it once retires it for every future record."
)
@@ -148,11 +165,16 @@ async def create_system(
Create one the moment two records would share an area that has no System
yet — the same two-or-more test snippets use. Don't wait to be asked to
name an area that plainly exists in the code; an unmodelled area means
every record about it stays untaggable. An audit or sweep that walks the
codebase is a DISCOVERY moment: mint the Systems it names as it names
them — the duplicate gate below, plus reviewing the existing list, is what
guards against sprawl, not holding back. Give each one a one-paragraph
name an area that plainly exists in the code, and don't route the
creation through operator approval — minting vocabulary is the agent's
call (#2798); an unmodelled area means every record about it stays
untaggable. An audit or sweep that walks the codebase is a DISCOVERY
moment: mint the Systems it names as it names them — the duplicate gate
below, plus reviewing the existing list, is what guards against sprawl,
not holding back. Prefer the standard cross-project names where the area
fits one (CI & Release, Auth & Access, Data Model & Storage, API Surface,
UI & Design, Import & Export, Background Jobs, Observability) so the same
word means the same thing in every project. Give each one a one-paragraph
charter, not just a label: the description is what tells a later session
whether a record belongs here.
+1
View File
@@ -44,5 +44,6 @@ from scribe.models.rulebook import ( # noqa: E402, F401
)
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
from scribe.models.code_shape import CodeShape # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
+98
View File
@@ -0,0 +1,98 @@
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin
# The classification vocabulary (note 2786). `unclassified` is the default and
# THE todo state; every other status is a judgment, stamped with who made it.
SHAPE_STATUSES = ("canonical", "instance", "variant", "exempt", "unclassified")
SHAPE_CLASSIFIERS = ("agent", "audit", "hook", "mechanical", "import")
class CodeShape(Base, TimestampMixin):
"""One extracted code shape and its classification against canon (#2787).
The accounting half of the pattern system (governing note 2786): the
snippet library records CANON (small); this ledger accounts for EVERY
shape the coverage extractor finds in a bound repo (total). A row's
status says how the shape relates to canon — it IS a snippet's reference
(`canonical`), conforms to one (`instance` — snippet_id may point at
another project's snippet, so family canon counts), departs deliberately
(`variant`, with the why in `reason`), was judged one-off (`exempt`,
a recorded judgment rather than silence), or awaits judgment
(`unclassified` — the todo).
Identity is (project, repo_key, path, symbol, kind) — kind is part of it
because one file can define `.foo` (css) and `foo` (sym) as distinct
shapes. A rename therefore reads as vanish + new row: accepted for v1,
because chasing renames needs content identity the extractor doesn't
have. `vanished_at` keeps the history instead of deleting it.
snippet_id is SET NULL on snippet deletion: the classification's target
is gone but the judgment happened; the sync pass (step 2) re-files such
rows as unclassified so they rejoin the todo instead of dangling.
"""
__tablename__ = "code_shapes"
__table_args__ = (
UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
Index("ix_code_shapes_project_status", "project_id", "status"),
Index("ix_code_shapes_snippet", "snippet_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
repo_key: Mapped[str] = mapped_column(Text, nullable=False)
path: Mapped[str] = mapped_column(Text, nullable=False)
symbol: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False) # "css" | "sym"
status: Mapped[str] = mapped_column(Text, nullable=False, default="unclassified")
snippet_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
classified_by: Mapped[str | None] = mapped_column(Text, nullable=True)
classified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
first_seen_commit: Mapped[str] = mapped_column(Text, default="")
last_seen_commit: Mapped[str] = mapped_column(Text, default="")
vanished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
def to_dict(self) -> dict:
return {
"id": self.id,
"project_id": self.project_id,
"repo_key": self.repo_key,
"path": self.path,
"symbol": self.symbol,
"kind": self.kind,
"status": self.status,
"snippet_id": self.snippet_id,
"reason": self.reason,
"classified_by": self.classified_by,
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
"first_seen_commit": self.first_seen_commit,
"last_seen_commit": self.last_seen_commit,
"vanished_at": self.vanished_at.isoformat() if self.vanished_at else None,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+1 -1
View File
@@ -123,7 +123,7 @@ async def delete_project_route(project_id: int):
@projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
@login_required
async def get_coverage_route(project_id: int):
"""The cached pattern-library coverage summary — never computes.
"""The cached shape-accounting summary — never computes.
`configured` tells the card whether offering a Refresh button makes
sense; `coverage` is null until something has computed it (a webhook
+56 -2
View File
@@ -11,6 +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.project import Project
from scribe.models.repo_binding import RepoBinding
from scribe.models.rulebook import (
@@ -36,8 +37,11 @@ logger = logging.getLogger(__name__)
# v6 (2026-08) added note_supersessions — and the guard did stop the seventh:
# the table shipped without a backup section and the coverage test failed the
# build, which is the whole reason that list was written.
# 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.
# Bump when the serialized schema changes.
BACKUP_VERSION = 6
BACKUP_VERSION = 7
# 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
@@ -55,6 +59,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",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -166,6 +172,10 @@ def _usage_event_rows(rows) -> list[dict]:
]
def _code_shape_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}
@@ -205,6 +215,7 @@ async def export_full_backup() -> dict:
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
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()
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all()
@@ -379,6 +390,7 @@ async def export_full_backup() -> dict:
"note_usage_events": _usage_event_rows(usage_events),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
}
@@ -446,6 +458,11 @@ async def export_user_backup(user_id: int) -> dict:
repo_bindings = (await session.execute(
select(RepoBinding).where(RepoBinding.user_id == user_id)
)).scalars().all()
# The ledger has no user_id of its own — rows belong to the project
# they account for, so a user's export carries their projects' rows.
code_shapes = (await session.execute(
select(CodeShape).where(CodeShape.project_id.in_(project_ids))
)).scalars().all() if project_ids else []
rulebooks = (await session.execute(
select(Rulebook).where(Rulebook.owner_user_id == user_id)
)).scalars().all()
@@ -634,6 +651,7 @@ async def export_user_backup(user_id: int) -> dict:
"note_usage_events": _usage_event_rows(usage_events),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
}
@@ -737,7 +755,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,
"note_supersessions": 0, "code_shapes": 0,
}
async with async_session() as session:
@@ -1114,6 +1132,42 @@ async def _restore_v2(data: dict) -> dict:
))
stats["repo_bindings"] += 1
# 21. Code shapes (v7, #2787) — the ledger's classifications are
# judgments worth carrying. A judgment whose snippet target didn't
# 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.
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:
continue
status = cs_data.get("status", "unclassified")
mapped_sid = note_id_map.get(cs_data.get("snippet_id") or 0)
classified_by = cs_data.get("classified_by")
classified_at = cs_data.get("classified_at")
if status in ("canonical", "instance", "variant") and mapped_sid is None:
status = "unclassified"
classified_by = None
classified_at = None
session.add(CodeShape(
project_id=mapped_pid,
repo_key=cs_data.get("repo_key", ""),
path=cs_data.get("path", ""),
symbol=cs_data.get("symbol", ""),
kind=cs_data.get("kind", "sym"),
status=status,
snippet_id=mapped_sid,
reason=cs_data.get("reason"),
classified_by=classified_by,
classified_at=_dt(classified_at) if classified_at else None,
first_seen_commit=cs_data.get("first_seen_commit", ""),
last_seen_commit=cs_data.get("last_seen_commit", ""),
vanished_at=_dt(cs_data["vanished_at"]) if cs_data.get("vanished_at") else None,
created_at=_dt(cs_data.get("created_at")),
updated_at=_dt(cs_data.get("updated_at")),
))
stats["code_shapes"] += 1
await session.commit()
logger.info("Restored v2/v3 backup: %s", stats)
+102 -83
View File
@@ -41,7 +41,10 @@ logger = logging.getLogger(__name__)
# Cache key in the settings KV, on the project OWNER's user_id — the same
# channel the scheduler's last-run summary uses for machine-written state.
_CACHE_KEY_PREFIX = "pattern_coverage_"
# v2 suffix with #2788: the payload shape inverted (accounted/unclassified);
# pre-ledger blobs under the old key simply stop being found, so the card
# honestly reads "not measured yet" until the first ledger-era refresh.
_CACHE_KEY_PREFIX = "pattern_coverage_v2_"
# Files whose content can't hold definitions — the hook's skip list, verbatim,
# plus sourcemaps (which are JSON in a trenchcoat).
@@ -157,77 +160,42 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
# --- matching shapes against recorded locations ------------------------------
def _norm_symbol(kind_or_symbol: str) -> str:
# CSS shapes and recorded CSS symbols may or may not carry the leading
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
return kind_or_symbol.lstrip(".").strip()
def _location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> bool:
if _norm_symbol(loc_symbol) != _norm_symbol(name):
return False
if not loc_path:
# Symbol-only record: the symbol match is all the claim there is.
return True
# The drift check's location semantics, not a second copy of them: exact
# file, or the recorded path is a directory the file lives under.
from scribe.services.snippets import _path_touches
return _path_touches(loc_path, path)
def match_shapes(
shapes: list[tuple[str, str, str]],
recorded: list[tuple[str, str]],
) -> list[tuple[str, str, str, bool]]:
"""Each shape with whether some recorded (path, symbol) location covers it.
Symbol-less recorded locations never cover a shape — a whole-file record
makes no claim about any particular definition inside it. The recorded
repo NAME is deliberately not consulted: it is free-form ("Scribe") and
the project binding already did the scoping; on a project binding several
repos this can over-credit a same-named symbol, which the estimate label
owns.
"""
usable = [(p, s) for p, s in recorded if (s or "").strip()]
return [
(
path,
kind,
name,
any(_location_covers(lp, ls, path, name) for lp, ls in usable),
)
for path, kind, name in shapes
]
# The covering predicate (location_covers) lives in services/shape_ledger.py
# since #2788 — the ledger's canonical marking and this module's readout are
# two consumers of ONE doctrine, and the ledger is its home.
def largest_gaps(
matched: list[tuple[str, str, str, bool]], *, top: int = 3
accounted: list[tuple[str, str, str, bool]], *, top: int = 3
) -> list[dict]:
"""The directories with the most uncovered shapes — where a backlog
session should start, named the way the repo names them."""
"""The directories with the most unclassified shapes — where a
classification session should start, named the way the repo names them."""
by_dir: dict[str, dict[str, int]] = {}
for path, _kind, _name, covered in matched:
for path, _kind, _name, is_accounted in accounted:
d = posixpath.dirname(path) or "(root)"
row = by_dir.setdefault(d, {"total": 0, "uncovered": 0})
row = by_dir.setdefault(d, {"total": 0, "unclassified": 0})
row["total"] += 1
if not covered:
row["uncovered"] += 1
if not is_accounted:
row["unclassified"] += 1
ranked = sorted(
by_dir.items(), key=lambda kv: (-kv[1]["uncovered"], kv[0])
by_dir.items(), key=lambda kv: (-kv[1]["unclassified"], kv[0])
)
return [
{"dir": d, "uncovered": row["uncovered"], "total": row["total"]}
{"dir": d, "unclassified": row["unclassified"], "total": row["total"]}
for d, row in ranked[:top]
if row["uncovered"]
if row["unclassified"]
]
# --- compute, cache, surface -------------------------------------------------
async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str, str]]:
"""(path, symbol) for every location of every live snippet in a project."""
async def _recorded_locations(
user_id: int, project_id: int
) -> list[tuple[int, str, str]]:
"""(snippet_note_id, path, symbol) for every location of every live
snippet in a project — the canonical-marking input (#2788): the id is what
lets a ledger row point back at the snippet it references."""
from sqlalchemy import select
from scribe.models import async_session
@@ -244,34 +212,46 @@ async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str,
)
)
notes = list(rows.scalars().all())
out: list[tuple[str, str]] = []
out: list[tuple[int, str, str]] = []
for note in notes:
for loc in snippet_fields(note).get("locations") or []:
out.append((loc.get("path") or "", loc.get("symbol") or ""))
out.append(
(int(note.id), loc.get("path") or "", loc.get("symbol") or "")
)
return out
async def compute_coverage(
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
) -> dict | None:
"""Measure a project's pattern-library coverage against its bound repos.
"""Sync the shape ledger from the bound repos and read the accounting.
Since #2788 this is the ledger's sync point, not just a measurement:
every walk upserts the extracted shapes (new → unclassified, vanished →
stamped), re-stamps snippet reference locations as canonical, and then
reports the ACCOUNTING — how many shapes carry a classification at all —
rather than the old "has a snippet" fraction. Unclassified is the todo
(note 2786).
None means "nothing to measure" — the owner's keyring serves none of the
project's bound repos (#2778). That is the ordinary state for a
forge-less user and every caller treats it as silence, not failure.
Forge errors (unreachable, bad token) RAISE — the two callers are a
refresh button and a background task, and both want to know.
forge-less user and every caller treats it as silence, not failure; the
ledger is untouched in that case. Forge errors (unreachable, bad token)
RAISE — the two callers are a refresh button and a background task, and
both want to know.
``user_id`` is the project OWNER's id: the cache lives there, and the
keyring resolved here must be the same one every other read uses.
"""
from scribe.services import shape_ledger
from scribe.services.forge import ForgeError
if selector is None:
selector = await get_forges(user_id, project_id)
if not selector.configured:
return None
repos: list[dict] = []
matched_all: list[tuple[str, str, str, bool]] = []
served: list[tuple[str, str]] = []
recorded = await _recorded_locations(user_id, project_id)
for key in await keys_for_project(user_id, project_id):
hit = selector.resolve(key)
@@ -280,26 +260,54 @@ async def compute_coverage(
forge, api_repo = hit
ref = await forge.default_branch(api_repo)
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
matched = match_shapes(shapes, recorded)
matched_all.extend(matched)
repos.append({
"repo": key,
"ref": ref,
"total": len(matched),
"recorded": sum(1 for *_x, covered in matched if covered),
})
if not repos:
# The head commit is provenance sugar on the ledger rows; failing to
# learn it must not fail the sync — the ref names the point well
# enough and the row timestamps carry the when.
try:
marker = await forge.latest_commit(api_repo, "", ref) or ref
except ForgeError:
marker = ref
await shape_ledger.sync_repo_shapes(
project_id, key, shapes, seen_marker=marker
)
served.append((key, ref))
if not served:
return None
await shape_ledger.mark_canonicals(project_id, recorded)
# Project-wide readout, deliberately wider than this walk: a second bound
# repo that was unreachable today still has live rows, and they count.
rows = await shape_ledger.live_rows(project_id)
counts = {"canonical": 0, "instance": 0, "variant": 0, "exempt": 0,
"unclassified": 0}
for row in rows:
counts[row.status] = counts.get(row.status, 0) + 1
by_repo: dict[str, dict[str, int]] = {}
for row in rows:
agg = by_repo.setdefault(row.repo_key, {"total": 0, "accounted": 0})
agg["total"] += 1
agg["accounted"] += row.status != "unclassified"
unclassified = counts.pop("unclassified")
return {
"total": len(matched_all),
"recorded": sum(1 for *_x, covered in matched_all if covered),
"total": len(rows),
"accounted": len(rows) - unclassified,
"unclassified": unclassified,
"counts": counts,
# Honesty flag, not decoration: every surface that shows the number
# is expected to carry it through.
"estimate": True,
"computed_at": datetime.now(timezone.utc).isoformat(),
"repos": repos,
"largest_gaps": largest_gaps(matched_all),
"repos": [
{"repo": key, "ref": ref,
**by_repo.get(key, {"total": 0, "accounted": 0})}
for key, ref in served
],
"largest_gaps": largest_gaps([
(r.path, r.kind, r.symbol, r.status != "unclassified")
for r in rows
]),
}
@@ -330,12 +338,23 @@ async def cached_coverage(user_id: int, project_id: int) -> dict | None:
def coverage_line(coverage: dict) -> str:
"""The one-line evidence-carrying summary enter_project surfaces."""
day = (coverage.get("computed_at") or "")[:10]
line = (
f"pattern-library coverage: {coverage.get('recorded', 0)}"
f"/{coverage.get('total', 0)} shapes recorded"
f" (estimate{', computed ' + day if day else ''})"
counts = coverage.get("counts") or {}
breakdown = " · ".join(
f"{counts[k]} {k}"
for k in ("canonical", "instance", "variant", "exempt")
if counts.get(k)
)
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
if gaps:
line += "; largest gaps: " + ", ".join(gaps)
line = (
f"shape accounting: {coverage.get('accounted', 0)}"
f"/{coverage.get('total', 0)} shapes accounted for"
)
if breakdown:
line += f"{breakdown}"
line += f" (estimate{', computed ' + day if day else ''})"
unclassified = coverage.get("unclassified", 0)
if unclassified:
line += f"; {unclassified} unclassified"
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
if gaps:
line += ", largest: " + ", ".join(gaps)
return line
+410
View File
@@ -0,0 +1,410 @@
"""The shape ledger's write side — sync and mechanical marking (#2788).
The coverage walk (services/coverage.py) is the only feed that sees every
shape, so it is the ledger's sync point: each refresh upserts one repo's
extracted shapes — new shapes arrive `unclassified` (THE todo state, note
2786), surviving shapes bump their last-seen marker, vanished shapes get
stamped rather than deleted (history is the point). Classifications survive
recompute by construction: the upsert never touches a judgment, with two
deliberate exceptions —
- a judgment whose snippet target is gone (SET NULL on snippet deletion)
is re-filed as unclassified so it rejoins the todo instead of dangling;
- a MECHANICALLY-stamped canonical row whose snippet location no longer
covers it falls back to unclassified. Only mechanical stamps self-heal;
an agent's judgment is never unwound by machinery.
`location_covers` is the one covering predicate — the same doctrine the
recorded-location drift check uses — shared by the sync's canonical marking
and by anything else that must decide whether a recorded location speaks for
an extracted shape.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import CodeShape
# Statuses whose meaning requires a snippet target.
_NEEDS_TARGET = ("canonical", "instance", "variant")
def _norm_symbol(kind_or_symbol: str) -> str:
# CSS shapes and recorded CSS symbols may or may not carry the leading
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
return kind_or_symbol.lstrip(".").strip()
def location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> bool:
"""Does a recorded (path, symbol) location speak for this shape?
Symbol-less locations never cover a shape — a whole-file record makes no
claim about any particular definition inside it. Path semantics are the
drift check's own: exact file, or the recorded path is a directory the
file lives under.
"""
if not (loc_symbol or "").strip():
return False
if _norm_symbol(loc_symbol) != _norm_symbol(name):
return False
if not loc_path:
# Symbol-only record: the symbol match is all the claim there is.
return True
from scribe.services.snippets import _path_touches
return _path_touches(loc_path, path)
async def sync_repo_shapes(
project_id: int,
repo_key: str,
shapes: list[tuple[str, str, str]],
*,
seen_marker: str,
) -> None:
"""Upsert one repo's extracted (path, kind, name) shapes into the ledger.
``seen_marker`` is the commit the archive was read at when the forge can
say, else the ref name — provenance sugar; the row timestamps carry the
when.
"""
now = datetime.now(timezone.utc)
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.repo_key == repo_key,
)
)
).scalars().all()
by_key = {(r.path, r.symbol, r.kind): r for r in rows}
seen: set[tuple[str, str, str]] = set()
for path, kind, name in shapes:
key = (path, name, kind)
if key in seen:
continue
seen.add(key)
row = by_key.get(key)
if row is None:
session.add(CodeShape(
project_id=project_id, repo_key=repo_key,
path=path, symbol=name, kind=kind,
first_seen_commit=seen_marker, last_seen_commit=seen_marker,
))
continue
row.last_seen_commit = seen_marker
# 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.status in _NEEDS_TARGET and row.snippet_id is None:
row.status = "unclassified"
row.classified_by = None
row.classified_at = None
row.reason = None
for key, row in by_key.items():
if key not in seen and row.vanished_at is None:
row.vanished_at = now
await session.commit()
async def mark_canonicals(
project_id: int, recorded: list[tuple[int, str, str]]
) -> None:
"""Stamp snippet reference locations as `canonical` — the one mechanical
rule that is always safe (the judgment happened when the snippet was
minted; this row just makes it queryable).
``recorded`` is (snippet_note_id, path, symbol) for every live snippet
location in the project. Touches only rows machinery owns: unclassified
rows gain the stamp; mechanically-stamped canonicals no longer covered
fall back to unclassified. Agent judgments are never overwritten.
"""
usable = [(nid, p, s) for nid, p, s in recorded if (s or "").strip()]
now = datetime.now(timezone.utc)
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()
for row in rows:
covering = next(
(
nid for nid, lp, ls in usable
if location_covers(lp, ls, row.path, row.symbol)
),
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
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 session.commit()
async def live_rows(project_id: int) -> list[CodeShape]:
"""Every un-vanished ledger row for a project — the accounting readout's
input, across ALL its repos (a repo unreachable this refresh still counts;
accounting is project-wide)."""
async with async_session() as session:
return list(
(
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.vanished_at.is_(None),
)
)
).scalars().all()
)
# --- classification (#2789): the judgment write path --------------------------
# Statuses an explicit classification may set. All five: setting a row back to
# `unclassified` is how a judgment is deliberately withdrawn.
_SETTABLE = ("canonical", "instance", "variant", "exempt", "unclassified")
# Who may appear as the classifier on this path. `hook` and `mechanical` are
# server-internal feeds (steps 5-6) — a caller claiming them would launder a
# judgment as machinery.
_CALLER_VIAS = ("agent", "audit", "import")
def validate_classifications(items: list[dict]) -> str | None:
"""The structural error a classification batch would earn, or None.
Pure and checked BEFORE anything is touched: a batch either applies or
errors whole — the StrictArgs lesson (#2709), a caller must never learn
later that half a batch silently happened.
"""
if not items:
return "classifications is empty — nothing to apply"
for i, item in enumerate(items):
if not isinstance(item, dict):
return f"classifications[{i}] is not an object"
path = (item.get("path") or "").strip()
symbol = (item.get("symbol") or "").strip()
if not path or not symbol:
return f"classifications[{i}] needs both path and symbol"
status = item.get("status") or ""
if status not in _SETTABLE:
return (
f"classifications[{i}] has unknown status {status!r} "
f"(one of: {', '.join(_SETTABLE)})"
)
snippet_id = item.get("snippet_id") or 0
if status in _NEEDS_TARGET and not snippet_id:
return (
f"classifications[{i}]: status {status!r} needs snippet_id — "
"the snippet this shape is (or departs from)"
)
if status in ("variant", "exempt") and not (item.get("reason") or "").strip():
return (
f"classifications[{i}]: status {status!r} needs a reason — "
"the WHY is the record (note 2786)"
)
return None
async def classify_shapes(
user_id: int,
project_id: int,
classifications: list[dict],
*,
via: str = "agent",
) -> dict:
"""Apply a batch of judgments to a project's live ledger rows.
All-or-nothing on errors: the whole batch is validated (structure, write
access, every snippet target readable by the caller) before any row is
touched. Rows are matched by exact (path, symbol) — plus kind when the
item carries one — and a target no live row matches is reported in
``unmatched``, not an error: the tree may simply have moved since the
caller listed. Idempotent by construction.
"""
from scribe.services import access
from scribe.services import snippets as snippets_svc
if via not in _CALLER_VIAS:
raise ValueError(f"via must be one of: {', '.join(_CALLER_VIAS)}")
error = validate_classifications(classifications)
if error:
raise ValueError(error)
if not await access.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found or no write access")
# Snippet targets resolve through the caller's own read access — a
# family-canon snippet in another project counts (note 2786), a snippet
# the caller cannot read does not exist for them.
target_ids = {
int(item["snippet_id"])
for item in classifications
if item.get("status") in _NEEDS_TARGET
}
for sid in sorted(target_ids):
if await snippets_svc.get_snippet(user_id, sid) is None:
raise ValueError(f"snippet {sid} not found (or not readable)")
now = datetime.now(timezone.utc)
classified = 0
unmatched: list[dict] = []
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_key: dict[tuple[str, str], list[CodeShape]] = {}
for row in rows:
by_key.setdefault((row.path, row.symbol), []).append(row)
for item in classifications:
matches = by_key.get(
((item.get("path") or "").strip(), (item.get("symbol") or "").strip())
) or []
kind = (item.get("kind") or "").strip()
if kind:
matches = [r for r in matches if r.kind == kind]
if not matches:
unmatched.append({
"path": item.get("path"), "symbol": item.get("symbol"),
})
continue
status = item["status"]
for row in matches:
row.status = status
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
classified += 1
await session.commit()
return {"classified": classified, "unmatched": unmatched}
async def list_project_shapes(
user_id: int,
project_id: int,
*,
status: str = "",
path: str = "",
snippet_id: int = 0,
include_vanished: bool = False,
limit: int = 100,
offset: int = 0,
) -> tuple[list[CodeShape], int]:
"""A filtered page of a project's ledger, with the unfiltered-match total.
([], 0) when the caller can't read the project — the same silence every
other project list gives. ``path`` matches the exact file or anything
beneath it, mirroring recorded-location semantics.
"""
from sqlalchemy import func, or_
from scribe.services import access
if not await access.can_read_project(user_id, project_id):
return [], 0
conds = [CodeShape.project_id == project_id]
if not include_vanished:
conds.append(CodeShape.vanished_at.is_(None))
if status:
conds.append(CodeShape.status == status)
if path:
clean = path.strip().strip("/")
conds.append(or_(
CodeShape.path == clean, CodeShape.path.like(clean + "/%")
))
if snippet_id:
conds.append(CodeShape.snippet_id == snippet_id)
async with async_session() as session:
total = (
await session.execute(
select(func.count()).select_from(CodeShape).where(*conds)
)
).scalar_one()
rows = (
await session.execute(
select(CodeShape).where(*conds)
.order_by(CodeShape.path, CodeShape.symbol, CodeShape.kind)
.limit(max(1, min(limit, 500))).offset(max(0, offset))
)
).scalars().all()
return list(rows), int(total)
def _consumer_dict(row: CodeShape) -> dict:
"""The compact shape a snippet's consumer map carries — enough to open
the file, none of the ledger bookkeeping."""
out = {
"project_id": row.project_id,
"repo": row.repo_key,
"path": row.path,
"symbol": row.symbol,
"kind": row.kind,
"classified_by": row.classified_by,
}
if row.reason:
out["reason"] = row.reason
return out
async def snippet_consumers(user_id: int, note_id: int) -> dict:
"""The structured consumer map for one snippet (#2789): its `instances`
(rows judged to conform) and `variants` (named departures, each carrying
its why). Rows are filtered to projects the CALLER can read — a shared
snippet must not become a side channel into someone else's project
layout. Empty lists mean "attach nothing" (#2483)."""
from scribe.services import access
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.snippet_id == note_id,
CodeShape.status.in_(("instance", "variant")),
CodeShape.vanished_at.is_(None),
)
)
).scalars().all()
readable: dict[int, bool] = {}
out: dict[str, list[dict]] = {"instances": [], "variants": []}
for row in rows:
if row.project_id not in readable:
readable[row.project_id] = await access.can_read_project(
user_id, row.project_id
)
if not readable[row.project_id]:
continue
out["instances" if row.status == "instance" else "variants"].append(
_consumer_dict(row)
)
return out
+221
View File
@@ -0,0 +1,221 @@
"""Real-Postgres integration tests for shape classification (#2789).
What mocks can't prove: the all-or-nothing batch against real rows, the
write-ACL gate, the todo query's filters, and the consumer map riding
get_snippet. Ledger rows are seeded through the same sync the coverage walk
uses — no forge needed, the sync takes extracted shapes directly.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session, engine
from scribe.models.code_shape import CodeShape
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services.shape_ledger import (
classify_shapes,
list_project_shapes,
snippet_consumers,
sync_repo_shapes,
)
pytestmark = pytest.mark.integration
REPO = "git.example.com/alice/widget"
SHAPES = [
("src/app.py", "sym", "make_app"),
("src/app.py", "sym", "Config"),
("src/util.py", "sym", "helper"),
("web/button.css", "css", "btn"),
]
@pytest_asyncio.fixture(autouse=True)
async def _dispose_engine():
"""Dispose the app's module-level engine after each test.
The engine pools asyncpg connections per event loop, but pytest-asyncio runs
each test on a fresh loop — so without this, test 2 gets handed test 1's
connection bound to a now-dead loop ("Future attached to a different loop").
Disposing in the test's own loop teardown clears the pool cleanly.
"""
yield
await engine.dispose()
async def _user(session, username: str) -> User:
existing = (
await session.execute(select(User).where(User.username == username))
).scalar_one_or_none()
if existing is not None:
return existing
user = User(username=username)
session.add(user)
await session.flush()
return user
@pytest_asyncio.fixture
async def seeded():
"""Owner + outsider, a project with a synced 4-shape ledger, one snippet."""
from scribe.services import snippets as snippets_svc
async with async_session() as s:
owner = await _user(s, "classify_owner")
other = await _user(s, "classify_other")
project = Project(user_id=owner.id, title="Classify target")
s.add(project)
await s.flush()
ids = {"owner": owner.id, "other": other.id, "pid": project.id}
await s.commit()
await sync_repo_shapes(ids["pid"], REPO, SHAPES, seen_marker="main")
snippet = await snippets_svc.create_snippet(
ids["owner"], name="cls_make_app", code="def make_app():\n pass\n",
language="python", repo="Widget", path="src/factory.py",
symbol="factory", project_id=ids["pid"],
)
ids["snippet"] = int(snippet.id)
return ids
@pytest.mark.integration
async def test_classify_applies_judgments_and_reports_unmatched(seeded):
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
out = await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
{"path": "src/util.py", "symbol": "helper", "status": "exempt",
"reason": "test scaffolding, deliberately local"},
{"path": "web/button.css", "symbol": "btn", "status": "variant",
"snippet_id": sid, "reason": "darker focus ring for the toolbar"},
{"path": "gone.py", "symbol": "nothing", "status": "exempt",
"reason": "x"},
], via="audit")
assert out["classified"] == 3
assert out["unmatched"] == [{"path": "gone.py", "symbol": "nothing"}]
rows, total = await list_project_shapes(owner, pid)
by_symbol = {r.symbol: r for r in rows}
assert total == 4
assert by_symbol["make_app"].status == "instance"
assert by_symbol["make_app"].snippet_id == sid
assert by_symbol["make_app"].classified_by == "audit"
assert by_symbol["helper"].status == "exempt"
assert by_symbol["helper"].reason == "test scaffolding, deliberately local"
assert by_symbol["btn"].status == "variant"
assert by_symbol["Config"].status == "unclassified"
# Withdrawing a judgment returns the shape to the todo, fields cleared.
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "unclassified"},
])
rows, _ = await list_project_shapes(owner, pid, status="unclassified")
assert {r.symbol for r in rows} == {"Config", "make_app"}
make_app = next(r for r in rows if r.symbol == "make_app")
assert make_app.snippet_id is None and make_app.classified_by is None
@pytest.mark.integration
async def test_a_bad_batch_applies_nothing(seeded):
"""All-or-nothing (#2709's lesson): a caller must never learn later that
half a batch silently happened."""
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
with pytest.raises(ValueError) as err:
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
{"path": "src/util.py", "symbol": "helper", "status": "variant",
"snippet_id": sid}, # variant with no reason: structural error
])
assert "needs a reason" in str(err.value)
rows, _ = await list_project_shapes(owner, pid, status="unclassified")
assert len(rows) == 4 # including make_app — the valid half did NOT apply
# A snippet target the caller can't read is the same: nothing applies.
with pytest.raises(ValueError):
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": 999999999},
])
@pytest.mark.integration
async def test_classification_is_write_gated_and_listing_read_gated(seeded):
other, pid, sid = seeded["other"], seeded["pid"], seeded["snippet"]
with pytest.raises(ValueError):
await classify_shapes(other, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "exempt",
"reason": "not their call to make"},
])
assert await list_project_shapes(other, pid) == ([], 0)
@pytest.mark.integration
async def test_list_filters_compose(seeded):
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
])
rows, total = await list_project_shapes(owner, pid, path="src")
assert total == 3 and all(r.path.startswith("src/") for r in rows)
# Directory semantics, not string prefix: "sr" matches nothing.
assert (await list_project_shapes(owner, pid, path="sr"))[1] == 0
rows, total = await list_project_shapes(owner, pid, snippet_id=sid)
assert total == 1 and rows[0].symbol == "make_app"
rows, total = await list_project_shapes(
owner, pid, status="unclassified", limit=2
)
assert total == 3 and len(rows) == 2 # paged, with the true total
@pytest.mark.integration
async def test_get_snippet_carries_the_structured_consumer_map(seeded):
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.snippets import get_snippet
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
{"path": "web/button.css", "symbol": "btn", "status": "variant",
"snippet_id": sid, "reason": "darker focus ring"},
])
token = _user_id_ctx.set(owner)
try:
data = await get_snippet(snippet_id=sid)
finally:
_user_id_ctx.reset(token)
assert [i["path"] for i in data["instances"]] == ["src/app.py"]
assert data["variants"][0]["reason"] == "darker focus ring"
# The map is caller-scoped: an outsider asking the service directly gets
# silence, not another project's file layout.
consumers = await snippet_consumers(seeded["other"], sid)
assert consumers == {"instances": [], "variants": []}
@pytest.mark.integration
async def test_sync_refiles_rows_whose_snippet_was_purged(seeded):
"""The SET NULL companion (#2787): a judgment whose target is hard-deleted
rejoins the todo on the next sync instead of dangling target-less."""
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
])
from scribe.models.note import Note
async with async_session() as s:
note = await s.get(Note, sid)
await s.delete(note) # hard delete, as purge_trash would
await s.commit()
await sync_repo_shapes(pid, REPO, SHAPES, seen_marker="main")
async with async_session() as s:
row = (await s.execute(select(CodeShape).where(
CodeShape.project_id == pid, CodeShape.symbol == "make_app",
))).scalar_one()
assert row.status == "unclassified"
assert row.snippet_id is None
+1 -1
View File
@@ -285,7 +285,7 @@ def _enter_project_stubs(p):
async def test_enter_project_carries_the_bootstrap_ask_when_it_fires():
"""The arrival-moment half of #2683: a mature zero-Systems project greets
the session with the concrete bootstrap ask, before it is deep in a task —
the moment "propose a starter vocabulary" is cheapest."""
the moment minting a starter vocabulary is cheapest."""
import contextlib
ask = "This project has 282 records and NO Systems modelled — ..."
+5 -1
View File
@@ -129,7 +129,11 @@ async def test_untagged_hint_escalates_in_a_mature_zero_systems_project():
assert "282 records" in hint
assert "Fix scrape retry backoff" in hint # the project's own evidence
assert "3-6" in hint and "create_system" in hint
assert "propose" in hint
# NOT an approval flow (#2798): the agent mints directly, and the
# standard cross-project names carry the consistency instead.
assert "without asking permission" in hint
assert "propose" not in hint and "confirmed" not in hint
assert "CI & Release" in hint and "Auth & Access" in hint
# The generic wording is REPLACED, not appended — two questions is noise.
assert "no Systems yet" not in hint
+116 -29
View File
@@ -19,10 +19,10 @@ from scribe.services.coverage import (
coverage_line,
extract_shapes,
largest_gaps,
match_shapes,
scannable,
shapes_from_archive,
)
from scribe.services.shape_ledger import location_covers
# --- unit: the definition extractor (shared vectors with the hook) -----------
@@ -117,51 +117,53 @@ def test_shapes_from_archive_strips_the_wrapper_and_gates_files():
assert shapes_from_archive(_tarball(TREE)) == TREE_SHAPES
# --- unit: matching shapes against recorded locations ------------------------
# --- unit: the covering predicate (lives with the ledger since #2788) --------
def test_match_covers_by_exact_path_dir_prefix_and_css_dot():
recorded = [
("src/app.py", "make_app"), # exact file
("web", ".btn"), # dir prefix + css dot normalization
]
matched = match_shapes(TREE_SHAPES, recorded)
covered = {name for _p, _k, name, ok in matched if ok}
assert covered == {"make_app", "btn"}
def test_location_covers_by_exact_path_dir_prefix_and_css_dot():
assert location_covers("src/app.py", "make_app", "src/app.py", "make_app")
# dir prefix + css dot normalization
assert location_covers("web", ".btn", "web/button.css", "btn")
assert not location_covers("src/app.py", "make_app", "src/util.py", "make_app")
def test_a_symbol_less_record_covers_nothing():
"""A whole-file snippet makes no claim about any particular definition
inside it — crediting all of them would inflate the number for free."""
matched = match_shapes(TREE_SHAPES, [("src/app.py", "")])
assert not any(ok for *_x, ok in matched)
assert not location_covers("src/app.py", "", "src/app.py", "make_app")
def test_no_prefix_bleed_between_sibling_directories():
matched = match_shapes(
[("src/library/x.py", "sym", "helper")], [("src/lib", "helper")]
)
assert not matched[0][3]
assert not location_covers("src/lib", "helper", "src/library/x.py", "helper")
def test_largest_gaps_ranks_by_uncovered_and_drops_clean_dirs():
matched = match_shapes(TREE_SHAPES, [("src/app.py", "make_app"), ("web", ".btn")])
gaps = largest_gaps(matched)
assert gaps == [{"dir": "src", "uncovered": 2, "total": 3}]
def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs():
accounted = [
("src/app.py", "sym", "make_app", True),
("src/app.py", "sym", "Config", False),
("src/util.py", "sym", "helper", False),
("web/button.css", "css", "btn", True),
]
gaps = largest_gaps(accounted)
assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}]
def test_coverage_line_is_evidence_carrying_and_labeled_estimate():
line = coverage_line({
"total": 210, "recorded": 34, "estimate": True,
"total": 4573, "accounted": 3100, "unclassified": 1473,
"counts": {"canonical": 12, "instance": 2900, "variant": 0, "exempt": 188},
"estimate": True,
"computed_at": "2026-08-16T12:00:00+00:00",
"largest_gaps": [
{"dir": "internal/api", "uncovered": 40, "total": 60},
{"dir": "web/src/components", "uncovered": 25, "total": 30},
{"dir": "internal/api", "unclassified": 40, "total": 60},
{"dir": "web/src/components", "unclassified": 25, "total": 30},
],
})
assert "34/210 shapes recorded" in line
assert "3100/4573 shapes accounted for" in line
assert "12 canonical · 2900 instance · 188 exempt" in line # zero variant elided
assert "estimate" in line
assert "2026-08-16" in line
assert "1473 unclassified" in line
assert "internal/api, web/src/components" in line
@@ -257,19 +259,52 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
refresh_coverage,
)
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import CodeShape
uid, pid = seeded["uid"], seeded["pid"]
selector = _selector(_tarball(TREE))
coverage = await compute_coverage(uid, pid, selector=selector)
assert coverage is not None
assert coverage["total"] == 4
assert coverage["recorded"] == 2
assert coverage["accounted"] == 2
assert coverage["unclassified"] == 2
assert coverage["counts"] == {
"canonical": 2, "instance": 0, "variant": 0, "exempt": 0,
}
assert coverage["estimate"] is True
assert coverage["repos"] == [{
"repo": "git.example.com/alice/widget", "ref": "main",
"total": 4, "recorded": 2,
"total": 4, "accounted": 2,
}]
assert coverage["largest_gaps"] == [{"dir": "src", "uncovered": 2, "total": 3}]
assert coverage["largest_gaps"] == [
{"dir": "src", "unclassified": 2, "total": 3}
]
# The walk fed the LEDGER (#2788): every extracted shape has a row, the
# snippet reference locations are mechanically stamped canonical WITH
# their snippet id, and the rest sit in the todo state.
async with async_session() as s:
rows = (await s.execute(
select(CodeShape).where(CodeShape.project_id == pid)
)).scalars().all()
by_symbol = {r.symbol: r for r in rows}
assert set(by_symbol) == {"make_app", "Config", "helper", "btn"}
assert by_symbol["make_app"].status == "canonical"
assert by_symbol["make_app"].snippet_id is not None
assert by_symbol["make_app"].classified_by == "mechanical"
assert by_symbol["btn"].status == "canonical"
assert by_symbol["Config"].status == "unclassified"
assert by_symbol["helper"].status == "unclassified"
assert all(r.vanished_at is None for r in rows)
assert all(r.first_seen_commit for r in rows) # ref at minimum
# Idempotence: a second walk changes nothing about the readout.
again = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
assert (again["total"], again["accounted"]) == (4, 2)
# Nothing computed → nothing cached; refresh writes; the cache reads back
# byte-equal, because enter_project will serve exactly this.
@@ -278,6 +313,57 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
assert (await cached_coverage(uid, pid)) == json.loads(json.dumps(stored))
@pytest.mark.integration
async def test_ledger_keeps_judgments_and_stamps_vanished_shapes(seeded):
"""The two survival rules (#2788): an agent's classification outlives
recompute, and a shape that leaves the tree is stamped vanished — kept
for history, dropped from the readout."""
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import CodeShape
from scribe.services.coverage import compute_coverage
uid, pid = seeded["uid"], seeded["pid"]
await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
# An agent judges `helper` a deliberate one-off.
async with async_session() as s:
helper = (await s.execute(select(CodeShape).where(
CodeShape.project_id == pid, CodeShape.symbol == "helper",
))).scalar_one()
helper.status = "exempt"
helper.reason = "test scaffolding, deliberately local"
helper.classified_by = "agent"
helper.classified_at = datetime.now(timezone.utc)
await s.commit()
# The tree moves on: util.py (helper) is gone entirely, app.py loses
# nothing. The judgment on `helper` must survive AS HISTORY (vanished,
# still exempt), never be reset by the sync.
smaller = {k: v for k, v in TREE.items() if k != "src/util.py"}
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(smaller)))
assert coverage["total"] == 3 # helper's row left the readout
assert coverage["accounted"] == 2
assert coverage["counts"]["exempt"] == 0 # vanished rows don't count
async with async_session() as s:
helper = (await s.execute(select(CodeShape).where(
CodeShape.project_id == pid, CodeShape.symbol == "helper",
))).scalar_one()
assert helper.vanished_at is not None
assert helper.status == "exempt" # the judgment is history, kept
assert helper.reason == "test scaffolding, deliberately local"
# And it returns: the shape reappearing clears the stamp, judgment intact.
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
assert coverage["total"] == 4
assert coverage["accounted"] == 3 # exempt counts as accounted again
assert coverage["counts"]["exempt"] == 1
@pytest.mark.integration
async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
from scribe.mcp._context import _user_id_ctx
@@ -296,9 +382,10 @@ async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
after = await enter_project(project_id=pid)
line = after["pattern_coverage"]
assert line.startswith(
"pattern-library coverage: 2/4 shapes recorded (estimate, computed "
"shape accounting: 2/4 shapes accounted for — 2 canonical "
"(estimate, computed "
)
assert line.endswith("; largest gaps: src")
assert line.endswith("; 2 unclassified, largest: src")
finally:
_user_id_ctx.reset(token)
+9 -7
View File
@@ -13,17 +13,19 @@ import pytest
from scribe.services import backup
def test_backup_version_is_v6():
"""v6 added note_supersessions (#278). The bump is the point of the test —
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 == 6
assert backup.BACKUP_VERSION == 7
def test_not_included_lists_the_known_gaps():
# The deferred tables must be surfaced explicitly, not silently dropped.
# forge_connections is excluded as CREDENTIALS (api_keys reasoning): a
# backup that carries forge tokens is a token-exfiltration file (#2778).
for table in ("groups", "project_shares", "note_shares", "api_keys",
"note_embeddings", "retrieval_logs"):
"note_embeddings", "retrieval_logs", "forge_connections"):
assert table in backup._NOT_INCLUDED
@@ -105,14 +107,14 @@ async def test_export_full_backup_contains_every_declared_section():
assert out["version"] == backup.BACKUP_VERSION
assert out["scope"] == "full"
assert "api_keys" in out["_not_included"]
# The sections v2 silently dropped, the six v5 added, and v6's
# note_supersessions (all empty here).
# The sections v2 silently dropped, the six v5 added, v6's
# note_supersessions, and v7's code_shapes (all empty here).
for key in ("rulebooks", "rulebook_topics", "rules",
"rulebook_subscriptions", "rule_suppressions",
"topic_suppressions",
"systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings",
"note_supersessions"):
"note_supersessions", "code_shapes"):
assert key in out, f"missing export section: {key}"
assert out[key] == []
+84
View File
@@ -0,0 +1,84 @@
"""The shape ledger's schema contract (#2787, milestone 294, note 2786).
Step 1 pins the model: identity, the classification vocabulary, and the
token-free serialisation. The sync pass (step 2) and the classification
surface (step 3) grow their tests here; DB-backed behavior lands in the
integration lane once there is behavior to exercise.
"""
from scribe.models import Base
from scribe.models.code_shape import SHAPE_CLASSIFIERS, SHAPE_STATUSES, CodeShape
def test_identity_is_project_repo_path_symbol_kind():
"""Kind is part of identity on purpose: one file can define `.foo` (css)
and `foo` (sym) as distinct shapes — the extractor emits both."""
table = Base.metadata.tables["code_shapes"]
unique = next(
c for c in table.constraints
if getattr(c, "name", "") == "uq_code_shapes_identity"
)
assert [c.name for c in unique.columns] == [
"project_id", "repo_key", "path", "symbol", "kind",
]
def test_the_todo_state_is_the_default():
"""A shape nobody has judged yet must read `unclassified` — the ledger's
todo list — never silently look classified."""
assert CodeShape.__table__.c.status.default.arg == "unclassified"
assert "unclassified" in SHAPE_STATUSES
assert set(SHAPE_STATUSES) == {
"canonical", "instance", "variant", "exempt", "unclassified",
}
assert set(SHAPE_CLASSIFIERS) == {
"agent", "audit", "hook", "mechanical", "import",
}
def test_snippet_reference_survives_snippet_deletion_as_null():
"""SET NULL, not CASCADE: a deleted snippet must not silently erase the
accounting rows that pointed at it — the sync pass re-files them as
unclassified so they rejoin the todo."""
fk = next(iter(CodeShape.__table__.c.snippet_id.foreign_keys))
assert fk.ondelete == "SET NULL"
assert fk.column.table.name == "notes"
def test_status_queries_have_an_index():
"""list_shapes(status=unclassified) is THE todo query (step 3) — it must
not degrade into a project-wide scan as ledgers reach thousands of rows."""
names = {ix.name for ix in CodeShape.__table__.indexes}
assert "ix_code_shapes_project_status" in names
assert "ix_code_shapes_snippet" in names
# --- step 3: the classification batch validator (pure, checked before ACL) ---
def test_batch_validation_names_the_failing_item():
from scribe.services.shape_ledger import validate_classifications as v
ok = {"path": "src/a.py", "symbol": "f", "status": "exempt", "reason": "one-off"}
assert v([ok]) is None
assert "empty" in v([])
assert "classifications[1]" in v([ok, {"symbol": "f", "status": "exempt"}])
assert "unknown status" in v([{**ok, "status": "covered"}])
# A judgment that references canon must name the canon...
assert "needs snippet_id" in v(
[{"path": "a", "symbol": "f", "status": "instance"}]
)
# ...and a departure/exemption must carry its why — the why IS the record.
assert "needs a reason" in v(
[{"path": "a", "symbol": "f", "status": "variant", "snippet_id": 3}]
)
assert "needs a reason" in v([{"path": "a", "symbol": "f", "status": "exempt"}])
# Withdrawing a judgment needs neither target nor reason.
assert v([{"path": "a", "symbol": "f", "status": "unclassified"}]) is None
def test_classify_and_list_are_mounted_as_mcp_tools():
from scribe.mcp.server import build_mcp_server
mcp = build_mcp_server()
for name in ("classify_shapes", "list_shapes"):
assert mcp._tool_manager.get_tool(name) is not None