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
+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)