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
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:
@@ -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")
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 — ..."
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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] == []
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user