Shape ledger steps 1-4 + direct-minting Systems bootstrap (milestone 294, #2798) #115
@@ -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 {
|
interface CoverageGap {
|
||||||
dir: string;
|
dir: string;
|
||||||
uncovered: number;
|
unclassified: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
interface Coverage {
|
interface Coverage {
|
||||||
total: number;
|
total: number;
|
||||||
recorded: number;
|
accounted: number;
|
||||||
|
unclassified: number;
|
||||||
|
counts: Record<string, number>;
|
||||||
estimate: boolean;
|
estimate: boolean;
|
||||||
computed_at: string;
|
computed_at: string;
|
||||||
repos: { repo: string; ref: string; total: number; recorded: number }[];
|
repos: { repo: string; ref: string; total: number; accounted: number }[];
|
||||||
largest_gaps: CoverageGap[];
|
largest_gaps: CoverageGap[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,32 +714,42 @@ async function confirmDelete() {
|
|||||||
</div>
|
</div>
|
||||||
<template v-if="coverage">
|
<template v-if="coverage">
|
||||||
<div class="coverage-numbers">
|
<div class="coverage-numbers">
|
||||||
<span class="coverage-count">{{ coverage.recorded }}/{{ coverage.total }}</span>
|
<span class="coverage-count">{{ coverage.accounted }}/{{ coverage.total }}</span>
|
||||||
<span class="coverage-label">shapes recorded</span>
|
<span class="coverage-label">shapes accounted for</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="coverage-bar"
|
class="coverage-bar"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
:aria-valuenow="coverage.recorded"
|
:aria-valuenow="coverage.accounted"
|
||||||
:aria-valuemin="0"
|
:aria-valuemin="0"
|
||||||
:aria-valuemax="coverage.total"
|
:aria-valuemax="coverage.total"
|
||||||
aria-label="Shapes with a recorded snippet"
|
aria-label="Shapes classified against canon"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="coverage-bar-fill"
|
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>
|
</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">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="coverage-empty">
|
<p v-else class="coverage-empty">
|
||||||
Not measured yet — Refresh compares the bound repo's definitions
|
Not measured yet — Refresh reads the bound repo's definitions into
|
||||||
against recorded snippets.
|
the shape ledger and reports how many are classified against canon.
|
||||||
</p>
|
</p>
|
||||||
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
|
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
|
||||||
<!-- Forge pin (#2778): owner-only, because the eligible set is the
|
<!-- Forge pin (#2778): owner-only, because the eligible set is the
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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.",
|
"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" },
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"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
|
exactly the signal that it was worth consolidating. This is the cure the create
|
||||||
gate only hints at when it blocks a near-duplicate.
|
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
|
## Why this pays off
|
||||||
|
|
||||||
A one-off written a second time is the cost this avoids — and at project
|
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;
|
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
||||||
create_system when the area is unmodelled.
|
create_system when the area is unmodelled.
|
||||||
- HOW to work: rules are pull-only and binding — call list_always_on_rules()
|
- 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
|
yourself at session start.
|
||||||
optimisation, not the bridge.
|
|
||||||
- UI: the project's design system is binding — resolve_design_system /
|
- UI: the project's design system is binding — resolve_design_system /
|
||||||
get_design_system_stylesheet before hand-writing a value.
|
get_design_system_stylesheet before hand-writing a value.
|
||||||
- REUSE: search snippets before writing a helper; record what you build with
|
- REUSE: search snippets before writing a helper; record what you build with
|
||||||
create_snippet. Saved procedures are Processes (follow verbatim). Deletes
|
create_snippet; classify shapes against canon (classify_shapes) — a
|
||||||
are trash-recoverable.
|
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
|
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
|
id — update it, don't force. shared:true records are another user's — a
|
||||||
suggestion, not the operator's settled practice.
|
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 /
|
# Which repos map to which project. Read-only by nature; bind_repo /
|
||||||
# unbind_repo are the writes.
|
# unbind_repo are the writes.
|
||||||
"list_repo_bindings",
|
"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
|
# 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 `mcp.server.build_mcp_server`.
|
||||||
"""
|
"""
|
||||||
from scribe.mcp.tools import (
|
from scribe.mcp.tools import (
|
||||||
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
|
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, shapes,
|
||||||
systems, tags, tasks, trash,
|
snippets, systems, tags, tasks, trash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,5 +24,6 @@ def register_all(mcp) -> None:
|
|||||||
repos.register(mcp)
|
repos.register(mcp)
|
||||||
processes.register(mcp)
|
processes.register(mcp)
|
||||||
snippets.register(mcp)
|
snippets.register(mcp)
|
||||||
|
shapes.register(mcp)
|
||||||
rulebooks.register(mcp)
|
rulebooks.register(mcp)
|
||||||
trash.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 —
|
open_tasks, recent_notes, design_system, systems, pattern_coverage —
|
||||||
plus systems_bootstrap, present only when it applies (see below).
|
plus systems_bootstrap, present only when it applies (see below).
|
||||||
|
|
||||||
`pattern_coverage` (usually null) is a one-line estimate of how much of
|
`pattern_coverage` (usually null) is the shape-accounting line — how many
|
||||||
the bound repo's code has recorded snippets — e.g. "pattern-library
|
of the bound repo's extracted shapes carry a classification against canon
|
||||||
coverage: 34/210 shapes recorded (estimate); largest gaps: internal/api".
|
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
|
||||||
When present, treat the gaps as a standing invitation: as you touch code
|
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
|
||||||
in those areas, record the shapes you find with create_snippet.
|
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
|
`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
|
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.
|
a subsystem's accumulated records with list_system_records.
|
||||||
|
|
||||||
`systems_bootstrap` appears ONLY when the project has many records and no
|
`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
|
Systems at all — act on it before starting other work: create_system a
|
||||||
vocabulary from the areas the project's records name, confirm it with the
|
starter vocabulary from the areas the project's records name, directly
|
||||||
operator, and create_system the confirmed set. It stops appearing the
|
and without asking permission, preferring the standard names the ask
|
||||||
moment the first System exists.
|
lists. It stops appearing the moment the first System exists.
|
||||||
|
|
||||||
`design_system` is null unless the project points at one. When present it
|
`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
|
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
|
# 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
|
# 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);
|
# write-moment half rides untagged-record responses (attach_systems);
|
||||||
# both retire the instant the first System exists.
|
# both retire the instant the first System exists.
|
||||||
systems_bootstrap = None
|
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
|
the source moved on — trust the location over the cached body and
|
||||||
consider verify_snippet after you look.
|
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
|
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
|
`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
|
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(
|
await systems_tools.attach_systems(
|
||||||
uid, note.user_id, data, note.id, note.project_id
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -336,6 +352,13 @@ async def verify_snippet(
|
|||||||
"moved to services/knowledge.py"). It's what makes the record fixable later
|
"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.
|
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
|
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
|
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.
|
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_MIN_RECORDS = 20
|
||||||
_BOOTSTRAP_TITLES = 6
|
_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:
|
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||||
"""The escalated vocabulary-bootstrap ask for a mature zero-Systems project.
|
"""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
|
(#2683). What separates the nudges that convert from the prose that
|
||||||
doesn't (the duplicate gate, the prior-art "already defined in 2 files")
|
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
|
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
|
count and the recent titles, and asks for a concrete deliverable: create
|
||||||
a starter set, confirm, 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
|
Self-retiring by construction: callers only reach for it while the
|
||||||
project has zero Systems, so the first create_system ends it everywhere.
|
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 (
|
return (
|
||||||
f"This project has {total} records and NO Systems modelled — none of "
|
f"This project has {total} records and NO Systems modelled — none of "
|
||||||
"them can be tagged to an area, so recurring problem-spots stay "
|
"them can be tagged to an area, so recurring problem-spots stay "
|
||||||
"invisible. Bootstrap the vocabulary now, in this session: from the "
|
"invisible. Bootstrap the vocabulary now, in this session, without "
|
||||||
f"areas the records themselves name (recent: {titles}), propose 3-6 "
|
"asking permission — creating Systems is your call, not an approval "
|
||||||
"Systems to the operator, create_system each confirmed one with a "
|
f"flow. From the areas the records themselves name (recent: {titles}), "
|
||||||
"one-paragraph charter, then tag this record (system_ids=[...]). "
|
"create_system 3-6 Systems, each with a one-paragraph charter, then "
|
||||||
"This ask repeats until the first System exists; answering it once "
|
"tag this record (system_ids=[...]). Where an area fits a standard "
|
||||||
"retires it for every future record."
|
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
|
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
|
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
|
name an area that plainly exists in the code, and don't route the
|
||||||
every record about it stays untaggable. An audit or sweep that walks the
|
creation through operator approval — minting vocabulary is the agent's
|
||||||
codebase is a DISCOVERY moment: mint the Systems it names as it names
|
call (#2798); an unmodelled area means every record about it stays
|
||||||
them — the duplicate gate below, plus reviewing the existing list, is what
|
untaggable. An audit or sweep that walks the codebase is a DISCOVERY
|
||||||
guards against sprawl, not holding back. Give each one a one-paragraph
|
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
|
charter, not just a label: the description is what tells a later session
|
||||||
whether a record belongs here.
|
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.repo_binding import RepoBinding # noqa: E402, F401
|
||||||
from scribe.models.forge_connection import ForgeConnection # 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.system import System, RecordSystem # noqa: E402, F401
|
||||||
from scribe.models.design_system import DesignSystem, DesignToken # 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"])
|
@projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
async def get_coverage_route(project_id: int):
|
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
|
`configured` tells the card whether offering a Refresh button makes
|
||||||
sense; `coverage` is null until something has computed it (a webhook
|
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.note_version import NoteVersion
|
||||||
from scribe.models.design_system import DesignSystem, DesignToken
|
from scribe.models.design_system import DesignSystem, DesignToken
|
||||||
from scribe.models.note_usage import NoteUsageEvent
|
from scribe.models.note_usage import NoteUsageEvent
|
||||||
|
from scribe.models.code_shape import CodeShape
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
from scribe.models.repo_binding import RepoBinding
|
from scribe.models.repo_binding import RepoBinding
|
||||||
from scribe.models.rulebook import (
|
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:
|
# 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
|
# the table shipped without a backup section and the coverage test failed the
|
||||||
# build, which is the whole reason that list was written.
|
# 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.
|
# 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
|
# 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
|
# 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.
|
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||||
"systems", "record_systems", "design_systems", "design_tokens",
|
"systems", "record_systems", "design_systems", "design_tokens",
|
||||||
"note_usage_events", "repo_bindings", "note_supersessions",
|
"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
|
# 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]:
|
def _repo_binding_rows(rows) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
|
{"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()
|
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
|
||||||
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
||||||
repo_bindings = (await session.execute(select(RepoBinding))).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()
|
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
|
||||||
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
||||||
rules = (await session.execute(select(Rule))).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),
|
"note_usage_events": _usage_event_rows(usage_events),
|
||||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||||
"note_supersessions": _note_supersession_rows(supersessions),
|
"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(
|
repo_bindings = (await session.execute(
|
||||||
select(RepoBinding).where(RepoBinding.user_id == user_id)
|
select(RepoBinding).where(RepoBinding.user_id == user_id)
|
||||||
)).scalars().all()
|
)).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(
|
rulebooks = (await session.execute(
|
||||||
select(Rulebook).where(Rulebook.owner_user_id == user_id)
|
select(Rulebook).where(Rulebook.owner_user_id == user_id)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
@@ -634,6 +651,7 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"note_usage_events": _usage_event_rows(usage_events),
|
"note_usage_events": _usage_event_rows(usage_events),
|
||||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||||
"note_supersessions": _note_supersession_rows(supersessions),
|
"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,
|
"topic_suppressions": 0,
|
||||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 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:
|
async with async_session() as session:
|
||||||
@@ -1114,6 +1132,42 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
))
|
))
|
||||||
stats["repo_bindings"] += 1
|
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()
|
await session.commit()
|
||||||
|
|
||||||
logger.info("Restored v2/v3 backup: %s", stats)
|
logger.info("Restored v2/v3 backup: %s", stats)
|
||||||
|
|||||||
+100
-81
@@ -41,7 +41,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Cache key in the settings KV, on the project OWNER's user_id — the same
|
# 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.
|
# 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,
|
# Files whose content can't hold definitions — the hook's skip list, verbatim,
|
||||||
# plus sourcemaps (which are JSON in a trenchcoat).
|
# 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 ------------------------------
|
# --- matching shapes against recorded locations ------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _norm_symbol(kind_or_symbol: str) -> str:
|
# The covering predicate (location_covers) lives in services/shape_ledger.py
|
||||||
# CSS shapes and recorded CSS symbols may or may not carry the leading
|
# since #2788 — the ledger's canonical marking and this module's readout are
|
||||||
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
|
# two consumers of ONE doctrine, and the ledger is its home.
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def largest_gaps(
|
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]:
|
) -> list[dict]:
|
||||||
"""The directories with the most uncovered shapes — where a backlog
|
"""The directories with the most unclassified shapes — where a
|
||||||
session should start, named the way the repo names them."""
|
classification session should start, named the way the repo names them."""
|
||||||
by_dir: dict[str, dict[str, int]] = {}
|
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)"
|
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
|
row["total"] += 1
|
||||||
if not covered:
|
if not is_accounted:
|
||||||
row["uncovered"] += 1
|
row["unclassified"] += 1
|
||||||
ranked = sorted(
|
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 [
|
return [
|
||||||
{"dir": d, "uncovered": row["uncovered"], "total": row["total"]}
|
{"dir": d, "unclassified": row["unclassified"], "total": row["total"]}
|
||||||
for d, row in ranked[:top]
|
for d, row in ranked[:top]
|
||||||
if row["uncovered"]
|
if row["unclassified"]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# --- compute, cache, surface -------------------------------------------------
|
# --- compute, cache, surface -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str, str]]:
|
async def _recorded_locations(
|
||||||
"""(path, symbol) for every location of every live snippet in a project."""
|
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 sqlalchemy import select
|
||||||
|
|
||||||
from scribe.models import async_session
|
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())
|
notes = list(rows.scalars().all())
|
||||||
out: list[tuple[str, str]] = []
|
out: list[tuple[int, str, str]] = []
|
||||||
for note in notes:
|
for note in notes:
|
||||||
for loc in snippet_fields(note).get("locations") or []:
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def compute_coverage(
|
async def compute_coverage(
|
||||||
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
|
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
|
||||||
) -> dict | 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
|
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
|
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-less user and every caller treats it as silence, not failure; the
|
||||||
Forge errors (unreachable, bad token) RAISE — the two callers are a
|
ledger is untouched in that case. Forge errors (unreachable, bad token)
|
||||||
refresh button and a background task, and both want to know.
|
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
|
``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.
|
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:
|
if selector is None:
|
||||||
selector = await get_forges(user_id, project_id)
|
selector = await get_forges(user_id, project_id)
|
||||||
if not selector.configured:
|
if not selector.configured:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
repos: list[dict] = []
|
served: list[tuple[str, str]] = []
|
||||||
matched_all: list[tuple[str, str, str, bool]] = []
|
|
||||||
recorded = await _recorded_locations(user_id, project_id)
|
recorded = await _recorded_locations(user_id, project_id)
|
||||||
for key in await keys_for_project(user_id, project_id):
|
for key in await keys_for_project(user_id, project_id):
|
||||||
hit = selector.resolve(key)
|
hit = selector.resolve(key)
|
||||||
@@ -280,26 +260,54 @@ async def compute_coverage(
|
|||||||
forge, api_repo = hit
|
forge, api_repo = hit
|
||||||
ref = await forge.default_branch(api_repo)
|
ref = await forge.default_branch(api_repo)
|
||||||
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
|
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
|
||||||
matched = match_shapes(shapes, recorded)
|
# The head commit is provenance sugar on the ledger rows; failing to
|
||||||
matched_all.extend(matched)
|
# learn it must not fail the sync — the ref names the point well
|
||||||
repos.append({
|
# enough and the row timestamps carry the when.
|
||||||
"repo": key,
|
try:
|
||||||
"ref": ref,
|
marker = await forge.latest_commit(api_repo, "", ref) or ref
|
||||||
"total": len(matched),
|
except ForgeError:
|
||||||
"recorded": sum(1 for *_x, covered in matched if covered),
|
marker = ref
|
||||||
})
|
await shape_ledger.sync_repo_shapes(
|
||||||
if not repos:
|
project_id, key, shapes, seen_marker=marker
|
||||||
|
)
|
||||||
|
served.append((key, ref))
|
||||||
|
if not served:
|
||||||
return None
|
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 {
|
return {
|
||||||
"total": len(matched_all),
|
"total": len(rows),
|
||||||
"recorded": sum(1 for *_x, covered in matched_all if covered),
|
"accounted": len(rows) - unclassified,
|
||||||
|
"unclassified": unclassified,
|
||||||
|
"counts": counts,
|
||||||
# Honesty flag, not decoration: every surface that shows the number
|
# Honesty flag, not decoration: every surface that shows the number
|
||||||
# is expected to carry it through.
|
# is expected to carry it through.
|
||||||
"estimate": True,
|
"estimate": True,
|
||||||
"computed_at": datetime.now(timezone.utc).isoformat(),
|
"computed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"repos": repos,
|
"repos": [
|
||||||
"largest_gaps": largest_gaps(matched_all),
|
{"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:
|
def coverage_line(coverage: dict) -> str:
|
||||||
"""The one-line evidence-carrying summary enter_project surfaces."""
|
"""The one-line evidence-carrying summary enter_project surfaces."""
|
||||||
day = (coverage.get("computed_at") or "")[:10]
|
day = (coverage.get("computed_at") or "")[:10]
|
||||||
line = (
|
counts = coverage.get("counts") or {}
|
||||||
f"pattern-library coverage: {coverage.get('recorded', 0)}"
|
breakdown = " · ".join(
|
||||||
f"/{coverage.get('total', 0)} shapes recorded"
|
f"{counts[k]} {k}"
|
||||||
f" (estimate{', computed ' + day if day else ''})"
|
for k in ("canonical", "instance", "variant", "exempt")
|
||||||
|
if counts.get(k)
|
||||||
)
|
)
|
||||||
|
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 []]
|
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
|
||||||
if gaps:
|
if gaps:
|
||||||
line += "; largest gaps: " + ", ".join(gaps)
|
line += ", largest: " + ", ".join(gaps)
|
||||||
return line
|
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():
|
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 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 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
|
import contextlib
|
||||||
|
|
||||||
ask = "This project has 282 records and NO Systems modelled — ..."
|
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 "282 records" in hint
|
||||||
assert "Fix scrape retry backoff" in hint # the project's own evidence
|
assert "Fix scrape retry backoff" in hint # the project's own evidence
|
||||||
assert "3-6" in hint and "create_system" in hint
|
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.
|
# The generic wording is REPLACED, not appended — two questions is noise.
|
||||||
assert "no Systems yet" not in hint
|
assert "no Systems yet" not in hint
|
||||||
|
|
||||||
|
|||||||
+116
-29
@@ -19,10 +19,10 @@ from scribe.services.coverage import (
|
|||||||
coverage_line,
|
coverage_line,
|
||||||
extract_shapes,
|
extract_shapes,
|
||||||
largest_gaps,
|
largest_gaps,
|
||||||
match_shapes,
|
|
||||||
scannable,
|
scannable,
|
||||||
shapes_from_archive,
|
shapes_from_archive,
|
||||||
)
|
)
|
||||||
|
from scribe.services.shape_ledger import location_covers
|
||||||
|
|
||||||
# --- unit: the definition extractor (shared vectors with the hook) -----------
|
# --- 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
|
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():
|
def test_location_covers_by_exact_path_dir_prefix_and_css_dot():
|
||||||
recorded = [
|
assert location_covers("src/app.py", "make_app", "src/app.py", "make_app")
|
||||||
("src/app.py", "make_app"), # exact file
|
# dir prefix + css dot normalization
|
||||||
("web", ".btn"), # 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")
|
||||||
matched = match_shapes(TREE_SHAPES, recorded)
|
|
||||||
covered = {name for _p, _k, name, ok in matched if ok}
|
|
||||||
assert covered == {"make_app", "btn"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_symbol_less_record_covers_nothing():
|
def test_a_symbol_less_record_covers_nothing():
|
||||||
"""A whole-file snippet makes no claim about any particular definition
|
"""A whole-file snippet makes no claim about any particular definition
|
||||||
inside it — crediting all of them would inflate the number for free."""
|
inside it — crediting all of them would inflate the number for free."""
|
||||||
matched = match_shapes(TREE_SHAPES, [("src/app.py", "")])
|
assert not location_covers("src/app.py", "", "src/app.py", "make_app")
|
||||||
assert not any(ok for *_x, ok in matched)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_prefix_bleed_between_sibling_directories():
|
def test_no_prefix_bleed_between_sibling_directories():
|
||||||
matched = match_shapes(
|
assert not location_covers("src/lib", "helper", "src/library/x.py", "helper")
|
||||||
[("src/library/x.py", "sym", "helper")], [("src/lib", "helper")]
|
|
||||||
)
|
|
||||||
assert not matched[0][3]
|
|
||||||
|
|
||||||
|
|
||||||
def test_largest_gaps_ranks_by_uncovered_and_drops_clean_dirs():
|
def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs():
|
||||||
matched = match_shapes(TREE_SHAPES, [("src/app.py", "make_app"), ("web", ".btn")])
|
accounted = [
|
||||||
gaps = largest_gaps(matched)
|
("src/app.py", "sym", "make_app", True),
|
||||||
assert gaps == [{"dir": "src", "uncovered": 2, "total": 3}]
|
("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():
|
def test_coverage_line_is_evidence_carrying_and_labeled_estimate():
|
||||||
line = coverage_line({
|
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",
|
"computed_at": "2026-08-16T12:00:00+00:00",
|
||||||
"largest_gaps": [
|
"largest_gaps": [
|
||||||
{"dir": "internal/api", "uncovered": 40, "total": 60},
|
{"dir": "internal/api", "unclassified": 40, "total": 60},
|
||||||
{"dir": "web/src/components", "uncovered": 25, "total": 30},
|
{"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 "estimate" in line
|
||||||
assert "2026-08-16" in line
|
assert "2026-08-16" in line
|
||||||
|
assert "1473 unclassified" in line
|
||||||
assert "internal/api, web/src/components" 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,
|
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"]
|
uid, pid = seeded["uid"], seeded["pid"]
|
||||||
selector = _selector(_tarball(TREE))
|
selector = _selector(_tarball(TREE))
|
||||||
|
|
||||||
coverage = await compute_coverage(uid, pid, selector=selector)
|
coverage = await compute_coverage(uid, pid, selector=selector)
|
||||||
assert coverage is not None
|
assert coverage is not None
|
||||||
assert coverage["total"] == 4
|
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["estimate"] is True
|
||||||
assert coverage["repos"] == [{
|
assert coverage["repos"] == [{
|
||||||
"repo": "git.example.com/alice/widget", "ref": "main",
|
"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
|
# Nothing computed → nothing cached; refresh writes; the cache reads back
|
||||||
# byte-equal, because enter_project will serve exactly this.
|
# 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))
|
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
|
@pytest.mark.integration
|
||||||
async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
|
async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
|
||||||
from scribe.mcp._context import _user_id_ctx
|
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)
|
after = await enter_project(project_id=pid)
|
||||||
line = after["pattern_coverage"]
|
line = after["pattern_coverage"]
|
||||||
assert line.startswith(
|
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:
|
finally:
|
||||||
_user_id_ctx.reset(token)
|
_user_id_ctx.reset(token)
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,19 @@ import pytest
|
|||||||
from scribe.services import backup
|
from scribe.services import backup
|
||||||
|
|
||||||
|
|
||||||
def test_backup_version_is_v6():
|
def test_backup_version_is_v7():
|
||||||
"""v6 added note_supersessions (#278). The bump is the point of the test —
|
"""v7 added code_shapes (#2787). The bump is the point of the test —
|
||||||
a payload section added without moving the version produces backups that
|
a payload section added without moving the version produces backups that
|
||||||
are structurally different and indistinguishable by inspection."""
|
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():
|
def test_not_included_lists_the_known_gaps():
|
||||||
# The deferred tables must be surfaced explicitly, not silently dropped.
|
# 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",
|
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
|
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["version"] == backup.BACKUP_VERSION
|
||||||
assert out["scope"] == "full"
|
assert out["scope"] == "full"
|
||||||
assert "api_keys" in out["_not_included"]
|
assert "api_keys" in out["_not_included"]
|
||||||
# The sections v2 silently dropped, the six v5 added, and v6's
|
# The sections v2 silently dropped, the six v5 added, v6's
|
||||||
# note_supersessions (all empty here).
|
# note_supersessions, and v7's code_shapes (all empty here).
|
||||||
for key in ("rulebooks", "rulebook_topics", "rules",
|
for key in ("rulebooks", "rulebook_topics", "rules",
|
||||||
"rulebook_subscriptions", "rule_suppressions",
|
"rulebook_subscriptions", "rule_suppressions",
|
||||||
"topic_suppressions",
|
"topic_suppressions",
|
||||||
"systems", "record_systems", "design_systems",
|
"systems", "record_systems", "design_systems",
|
||||||
"design_tokens", "note_usage_events", "repo_bindings",
|
"design_tokens", "note_usage_events", "repo_bindings",
|
||||||
"note_supersessions"):
|
"note_supersessions", "code_shapes"):
|
||||||
assert key in out, f"missing export section: {key}"
|
assert key in out, f"missing export section: {key}"
|
||||||
assert out[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