feat(coverage): pattern-library coverage measurement (#2692, milestone 288 step 7)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 41s

Server-side shape enumeration per bound repo — one archive download via the
forge adapter, definitions extracted with a Python mirror of the write-path
hook's awk rules (shared test vectors pin the two together) — compared
against recorded snippet locations by path+symbol. Summary is cached in the
settings KV with a freshness stamp; recomputed on webhook push (spawned off
the delivery path) or explicit refresh, never in a request path.

Surfaces: GET/POST /api/projects/<id>/coverage[/refresh], a project-page
card (estimate-labeled, largest-gaps chips), and a one-line evidence-carrying
entry in enter_project read from cache only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 16:05:43 -04:00
co-authored by Claude Fable 5
parent 89b07f7857
commit cbccb6bd5d
8 changed files with 957 additions and 2 deletions
+17 -1
View File
@@ -17,6 +17,7 @@ keeps working.
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import coverage as coverage_svc
from scribe.services import design_systems as design_systems_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
@@ -56,7 +57,13 @@ async def enter_project(project_id: int) -> dict:
Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated,
open_tasks, recent_notes, design_system, systems.
open_tasks, recent_notes, design_system, systems, pattern_coverage.
`pattern_coverage` (usually null) is a one-line estimate of how much of
the bound repo's code has recorded snippets — e.g. "pattern-library
coverage: 34/210 shapes recorded (estimate); largest gaps: internal/api".
When present, treat the gaps as a standing invitation: as you touch code
in those areas, record the shapes you find with create_snippet.
`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
@@ -128,8 +135,17 @@ async def enter_project(project_id: int) -> dict:
uid, project.design_system_id,
)
# Cache read ONLY — computing coverage moves a repo tarball and never
# belongs in this request path. Null is the ordinary state (no forge, or
# never computed); the line appears exactly when there is evidence. Read
# on the OWNER's id: bindings and the cache live with the project owner.
coverage = await coverage_svc.cached_coverage(
project.user_id or uid, project_id
)
return {
"project": project.to_dict(),
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
# Trimmed to what tagging needs. The full charter is get_system's job —
# this list rides along on every session start, so it stays lean.
"systems": [
+55
View File
@@ -120,6 +120,61 @@ async def delete_project_route(project_id: int):
return "", 204
@projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
@login_required
async def get_coverage_route(project_id: int):
"""The cached pattern-library coverage summary — never computes.
`configured` tells the card whether offering a Refresh button makes
sense; `coverage` is null until something has computed it (a webhook
push or an explicit refresh).
"""
from scribe.services.coverage import cached_coverage
from scribe.services.forge import get_forge
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, _ = result
owner_uid = project.user_id or uid
return jsonify({
"configured": await get_forge() is not None,
"coverage": await cached_coverage(owner_uid, project_id),
})
@projects_bp.route("/<int:project_id>/coverage/refresh", methods=["POST"])
@login_required
async def refresh_coverage_route(project_id: int):
"""Recompute coverage now (archive fetch — seconds, not milliseconds).
Synchronous on purpose: the caller is a person who just clicked Refresh
and wants the new number, and the forge timeout bounds the wait.
"""
from scribe.services.coverage import refresh_coverage
from scribe.services.forge import ForgeError, get_forge
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, _ = result
owner_uid = project.user_id or uid
if await get_forge() is None:
return jsonify({"error": "No git forge is configured (Settings → Config → Git Forge)"}), 400
try:
coverage = await refresh_coverage(owner_uid, project_id)
except ForgeError as exc:
return jsonify({"error": str(exc)}), 502
if coverage is None:
return jsonify({
"error": "No bound repo is served by the configured forge — "
"bind the project's repo (bind_repo) on a remote the forge hosts"
}), 400
return jsonify({"coverage": coverage})
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required
async def get_project_notes_route(project_id: int):
+13 -1
View File
@@ -29,7 +29,9 @@ import traceback
from quart import Blueprint, jsonify, request
from scribe.config import Config
from scribe.services.repo_bindings import normalize_repo_key
from scribe.services.background import spawn
from scribe.services.coverage import refresh_coverage
from scribe.services.repo_bindings import bindings_for_key, normalize_repo_key
from scribe.services.settings import get_admin_setting
from scribe.services.snippets import invalidate_for_push
@@ -88,6 +90,16 @@ async def forge_push():
logger.info(
"forge push %s flagged %d snippet(s) for recheck", head[:12], flagged
)
# A push is exactly when the coverage number goes stale — recompute it
# off the delivery path (#2692). Fire-and-forget: the forge's delivery
# loop must not wait on an archive download, and a failure is a
# WARNING from spawn(), never a failed delivery. This also SEEDS the
# cache on a webhook-configured instance — no manual first refresh.
for binding in await bindings_for_key(repo_key):
spawn(
refresh_coverage(binding.user_id, binding.project_id),
site="webhooks.coverage_refresh",
)
return jsonify({"ok": True, "flagged": flagged})
except Exception:
logger.warning("forge webhook processing failed", exc_info=True)
+336
View File
@@ -0,0 +1,336 @@
"""Pattern-library coverage — what fraction of a bound repo's shapes have a
recorded snippet (#2692, forge job 3 of decision #2686).
The all-shapes doctrine says every shape gets recorded at first build. This
module is the hoping→knowing move: it enumerates the definitions that exist in
a project's bound repos (via the forge, one archive download per repo) and
compares them against recorded snippet locations, so "record everything"
becomes a watched number instead of an aspiration.
The definition extractor MIRRORS the write-path hook's awk rules
(plugin/hooks/scribe_prior_art.sh, ARM 1) — one shared notion of "a
definition" between the hook and the server, so the metric and the backstop
agree on what counts. The two are pinned together by shared test vectors in
tests/test_pattern_coverage.py; change one, change both.
The number is an ESTIMATE and every surface must say so: keyword extraction
over-counts (private one-offs, generated code that slips the dir filter) and
under-counts (keyword-less declaration syntax — C/Java/Dart — needs a real
parser and is out of scope, exactly as it is for the hook). The trend carries
the meaning, like the usage counters; the raw number is not a grade.
Compute is on demand + cached with a freshness stamp — recomputed on webhook
push and explicit refresh, NEVER in the request path of enter_project, which
only ever reads the cache.
"""
from __future__ import annotations
import io
import json
import logging
import posixpath
import re
import tarfile
from datetime import datetime, timezone
from scribe.services.forge import GiteaForge, get_forge
from scribe.services.repo_bindings import keys_for_project
from scribe.services.settings import get_setting, set_setting
logger = logging.getLogger(__name__)
# Cache key in the settings KV, on the project OWNER's user_id — the same
# channel the scheduler's last-run summary uses for machine-written state.
_CACHE_KEY_PREFIX = "pattern_coverage_"
# Files whose content can't hold definitions — the hook's skip list, verbatim,
# plus sourcemaps (which are JSON in a trenchcoat).
_SKIP_SUFFIXES = (
".md", ".mdx", ".txt", ".rst", ".json", ".lock", ".log", ".csv", ".tsv",
".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".map",
)
# Vendored/generated trees would swamp the metric with shapes nobody should
# record — the dunder-skip lesson at directory scale: guaranteed noise teaches
# people to ignore the number.
_SKIP_DIRS = frozenset({
"node_modules", "vendor", "dist", "build", "target",
"__pycache__", ".git", ".venv", "venv",
})
# A single source file bigger than this is almost certainly generated or
# vendored (bundles, lockstep protos) — skipped, and part of why the number
# is labeled an estimate.
_MAX_FILE_BYTES = 1_000_000
# --- the definition extractor (mirror of scribe_prior_art.sh ARM 1) ----------
_CSS_RE = re.compile(r"^\s*\.([A-Za-z][A-Za-z0-9_-]*)\s*[,{]")
# Leading declaration modifiers, so the definition keyword is the first word
# regardless of language (export/pub/private/suspend/...).
_MODIFIERS_RE = re.compile(
r"^(?:(?:pub(?:\([a-z]+\))?|export|default|private|internal|protected"
r"|public|static|suspend|async|open|sealed|data|abstract|final|inline"
r"|unsafe|extern|override)\s+)*"
)
# Go method with receiver: func (r *T) Name(
_GO_METHOD_RE = re.compile(r"^func\s*\([^)]*\)\s*([A-Za-z_][A-Za-z0-9_]*)")
# Keyword-announced definitions, functions and named types alike. `impl` is
# excluded on purpose — several per type is normal Rust, not duplication.
_KEYWORD_RE = re.compile(
r"^(?:function|def|class|func|fun|fn|sub|struct|trait|interface|enum"
r"|object|protocol|type)\s+([A-Za-z_$][A-Za-z0-9_$]*)"
)
# Arrow/expression assignment: const name = (…) / let name = async (
_ARROW_RE = re.compile(
r"^(?:const|let)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?[(<]"
)
def extract_shapes(text: str) -> list[tuple[str, str]]:
"""Every (kind, name) this text DEFINES — kind is "css" or "sym".
Rule-for-rule mirror of the hook's awk program: first match wins per
line, dunders are skipped (every class defines __init__ — guaranteed
noise), duplicates within one text count once.
"""
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for raw in text.splitlines():
m = _CSS_RE.match(raw)
if m:
shape = ("css", m.group(1))
else:
line = _MODIFIERS_RE.sub("", raw.lstrip())
if m := _GO_METHOD_RE.match(line):
shape = ("sym", m.group(1))
elif m := _KEYWORD_RE.match(line):
name = m.group(1)
if name.startswith("__") and name.endswith("__"):
continue
shape = ("sym", name)
elif m := _ARROW_RE.match(line):
shape = ("sym", m.group(1))
else:
continue
if shape not in seen:
seen.add(shape)
out.append(shape)
return out
def scannable(path: str) -> bool:
"""Should this repo file be scanned for shapes at all?"""
parts = path.split("/")
if any(p in _SKIP_DIRS for p in parts[:-1]):
return False
return not path.lower().endswith(_SKIP_SUFFIXES)
def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
"""(path, kind, name) for every definition in a repo tarball.
Forge archives wrap content in a single top-level directory (repo-ref/);
that component is stripped so paths match recorded snippet locations,
which are repo-relative. Non-UTF-8 files are binaries and skipped.
"""
shapes: list[tuple[str, str, str]] = []
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
for member in tar:
if not member.isfile() or "/" not in member.name:
continue
path = member.name.split("/", 1)[1]
if not path or not scannable(path) or member.size > _MAX_FILE_BYTES:
continue
handle = tar.extractfile(member)
if handle is None:
continue
try:
text = handle.read().decode("utf-8")
except UnicodeDecodeError:
continue
shapes.extend((path, kind, name) for kind, name in extract_shapes(text))
return shapes
# --- matching shapes against recorded locations ------------------------------
def _norm_symbol(kind_or_symbol: str) -> str:
# CSS shapes and recorded CSS symbols may or may not carry the leading
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
return kind_or_symbol.lstrip(".").strip()
def _location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> bool:
if _norm_symbol(loc_symbol) != _norm_symbol(name):
return False
if not loc_path:
# Symbol-only record: the symbol match is all the claim there is.
return True
# The drift check's location semantics, not a second copy of them: exact
# file, or the recorded path is a directory the file lives under.
from scribe.services.snippets import _path_touches
return _path_touches(loc_path, path)
def match_shapes(
shapes: list[tuple[str, str, str]],
recorded: list[tuple[str, str]],
) -> list[tuple[str, str, str, bool]]:
"""Each shape with whether some recorded (path, symbol) location covers it.
Symbol-less recorded locations never cover a shape — a whole-file record
makes no claim about any particular definition inside it. The recorded
repo NAME is deliberately not consulted: it is free-form ("Scribe") and
the project binding already did the scoping; on a project binding several
repos this can over-credit a same-named symbol, which the estimate label
owns.
"""
usable = [(p, s) for p, s in recorded if (s or "").strip()]
return [
(
path,
kind,
name,
any(_location_covers(lp, ls, path, name) for lp, ls in usable),
)
for path, kind, name in shapes
]
def largest_gaps(
matched: list[tuple[str, str, str, bool]], *, top: int = 3
) -> list[dict]:
"""The directories with the most uncovered shapes — where a backlog
session should start, named the way the repo names them."""
by_dir: dict[str, dict[str, int]] = {}
for path, _kind, _name, covered in matched:
d = posixpath.dirname(path) or "(root)"
row = by_dir.setdefault(d, {"total": 0, "uncovered": 0})
row["total"] += 1
if not covered:
row["uncovered"] += 1
ranked = sorted(
by_dir.items(), key=lambda kv: (-kv[1]["uncovered"], kv[0])
)
return [
{"dir": d, "uncovered": row["uncovered"], "total": row["total"]}
for d, row in ranked[:top]
if row["uncovered"]
]
# --- compute, cache, surface -------------------------------------------------
async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str, str]]:
"""(path, symbol) for every location of every live snippet in a project."""
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.services.snippets import SNIPPET_NOTE_TYPE, snippet_fields
async with async_session() as session:
rows = await session.execute(
select(Note).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.note_type == SNIPPET_NOTE_TYPE,
Note.deleted_at.is_(None),
)
)
notes = list(rows.scalars().all())
out: list[tuple[str, str]] = []
for note in notes:
for loc in snippet_fields(note).get("locations") or []:
out.append((loc.get("path") or "", loc.get("symbol") or ""))
return out
async def compute_coverage(
user_id: int, project_id: int, *, forge: GiteaForge | None = None
) -> dict | None:
"""Measure a project's pattern-library coverage against its bound repos.
None means "nothing to measure" — no forge configured, or none of the
project's bound repos is served by it. That is the ordinary state for a
forge-less install and every caller treats it as silence, not failure.
Forge errors (unreachable, bad token) RAISE — the two callers are a
refresh button and a background task, and both want to know.
"""
forge = forge if forge is not None else await get_forge()
if forge is None:
return None
repos: list[dict] = []
matched_all: list[tuple[str, str, str, bool]] = []
recorded = await _recorded_locations(user_id, project_id)
for key in await keys_for_project(user_id, project_id):
api_repo = forge.resolve_repo(key)
if api_repo is None:
continue # bound to a host this forge doesn't serve
ref = await forge.default_branch(api_repo)
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
matched = match_shapes(shapes, recorded)
matched_all.extend(matched)
repos.append({
"repo": key,
"ref": ref,
"total": len(matched),
"recorded": sum(1 for *_x, covered in matched if covered),
})
if not repos:
return None
return {
"total": len(matched_all),
"recorded": sum(1 for *_x, covered in matched_all if covered),
# Honesty flag, not decoration: every surface that shows the number
# is expected to carry it through.
"estimate": True,
"computed_at": datetime.now(timezone.utc).isoformat(),
"repos": repos,
"largest_gaps": largest_gaps(matched_all),
}
async def refresh_coverage(
user_id: int, project_id: int, *, forge: GiteaForge | None = None
) -> dict | None:
"""Compute and cache. The only writer of the cache key."""
coverage = await compute_coverage(user_id, project_id, forge=forge)
if coverage is not None:
await set_setting(
user_id, f"{_CACHE_KEY_PREFIX}{project_id}", json.dumps(coverage)
)
return coverage
async def cached_coverage(user_id: int, project_id: int) -> dict | None:
"""The last computed summary, or None — never computes."""
raw = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}", "")
if not raw:
return None
try:
parsed = json.loads(raw)
except ValueError:
return None
return parsed if isinstance(parsed, dict) else None
def coverage_line(coverage: dict) -> str:
"""The one-line evidence-carrying summary enter_project surfaces."""
day = (coverage.get("computed_at") or "")[:10]
line = (
f"pattern-library coverage: {coverage.get('recorded', 0)}"
f"/{coverage.get('total', 0)} shapes recorded"
f" (estimate{', computed ' + day if day else ''})"
)
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
if gaps:
line += "; largest gaps: " + ", ".join(gaps)
return line
+22
View File
@@ -58,6 +58,12 @@ FORGE_KINDS = ("gitea",)
# hung socket, and there is no retry: the fallback IS the retry policy.
_TIMEOUT = httpx.Timeout(5.0)
# Archive downloads move a whole-repo tarball and only ever run off the
# request path (coverage recompute, step 7), so they get a bigger budget than
# the per-file reads — but still a bound, because a hung background task
# holds a connection slot as surely as a foreground one.
_ARCHIVE_TIMEOUT = httpx.Timeout(60.0)
class ForgeError(RuntimeError):
"""A forge call failed (network, auth, unexpected payload). Token-free."""
@@ -179,6 +185,22 @@ class GiteaForge:
path=payload.get("path") or path,
)
async def archive(self, repo: str, ref: str) -> bytes:
"""The repo's content at ``ref`` as a gzipped tarball, in one request.
Coverage measurement (step 7) needs every source file's text; per-file
reads would mean one API call per file, so the archive endpoint is the
only shape that scales past toy repos. Callers must never run this in
a request path — it moves the whole repo.
"""
async with self._client() as client:
resp = await self._get(
client,
f"/repos/{repo}/archive/{quote(ref, safe='')}.tar.gz",
timeout=_ARCHIVE_TIMEOUT,
)
return resp.content
async def default_branch(self, repo: str) -> str:
async with self._client() as client:
resp = await self._get(client, f"/repos/{repo}")