CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 44s
compute_coverage is now the ledger's sync point: every walk upserts the extracted shapes (new → unclassified, the todo state; surviving → last-seen bump; vanished → stamped, kept as history), re-files judgments whose snippet target went away, and mechanically stamps snippet reference locations as canonical — the one always-safe rule, self-healing only for its own stamps (an agent's judgment is never unwound by machinery). The covering predicate moves to shape_ledger.location_covers as the single home (match_shapes retired with its consumer); coverage's payload and line invert from 'N/M shapes recorded' to shape ACCOUNTING per note 2786: accounted/total with a canonical·instance·variant·exempt breakdown, and unclassified — THE todo — with its largest directories. Cache key bumps to v2 so pre-ledger blobs honestly read 'not measured yet' instead of rendering in a shape no longer spoken. Readout is deliberately project-wide (all repos' live rows), while the walk serves whichever repos the owner's keyring reaches this refresh. Integration tests pin the new contract: rows for every extracted shape, mechanical canonical stamps carrying snippet ids, idempotent recompute, agent judgments surviving recompute AND vanish/return, vanished rows leaving the readout but keeping their history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""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 ForgeSelector, get_forges
|
|
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.
|
|
# v2 suffix with #2788: the payload shape inverted (accounted/unclassified);
|
|
# pre-ledger blobs under the old key simply stop being found, so the card
|
|
# honestly reads "not measured yet" until the first ledger-era refresh.
|
|
_CACHE_KEY_PREFIX = "pattern_coverage_v2_"
|
|
|
|
# Files whose content can't hold definitions — the hook's skip list, verbatim,
|
|
# plus sourcemaps (which are JSON in a trenchcoat).
|
|
_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 ------------------------------
|
|
|
|
|
|
# The covering predicate (location_covers) lives in services/shape_ledger.py
|
|
# since #2788 — the ledger's canonical marking and this module's readout are
|
|
# two consumers of ONE doctrine, and the ledger is its home.
|
|
|
|
|
|
def largest_gaps(
|
|
accounted: list[tuple[str, str, str, bool]], *, top: int = 3
|
|
) -> list[dict]:
|
|
"""The directories with the most unclassified shapes — where a
|
|
classification session should start, named the way the repo names them."""
|
|
by_dir: dict[str, dict[str, int]] = {}
|
|
for path, _kind, _name, is_accounted in accounted:
|
|
d = posixpath.dirname(path) or "(root)"
|
|
row = by_dir.setdefault(d, {"total": 0, "unclassified": 0})
|
|
row["total"] += 1
|
|
if not is_accounted:
|
|
row["unclassified"] += 1
|
|
ranked = sorted(
|
|
by_dir.items(), key=lambda kv: (-kv[1]["unclassified"], kv[0])
|
|
)
|
|
return [
|
|
{"dir": d, "unclassified": row["unclassified"], "total": row["total"]}
|
|
for d, row in ranked[:top]
|
|
if row["unclassified"]
|
|
]
|
|
|
|
|
|
# --- compute, cache, surface -------------------------------------------------
|
|
|
|
|
|
async def _recorded_locations(
|
|
user_id: int, project_id: int
|
|
) -> list[tuple[int, str, str]]:
|
|
"""(snippet_note_id, path, symbol) for every location of every live
|
|
snippet in a project — the canonical-marking input (#2788): the id is what
|
|
lets a ledger row point back at the snippet it references."""
|
|
from sqlalchemy import select
|
|
|
|
from scribe.models import async_session
|
|
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[int, str, str]] = []
|
|
for note in notes:
|
|
for loc in snippet_fields(note).get("locations") or []:
|
|
out.append(
|
|
(int(note.id), loc.get("path") or "", loc.get("symbol") or "")
|
|
)
|
|
return out
|
|
|
|
|
|
async def compute_coverage(
|
|
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
|
|
) -> dict | None:
|
|
"""Sync the shape ledger from the bound repos and read the accounting.
|
|
|
|
Since #2788 this is the ledger's sync point, not just a measurement:
|
|
every walk upserts the extracted shapes (new → unclassified, vanished →
|
|
stamped), re-stamps snippet reference locations as canonical, and then
|
|
reports the ACCOUNTING — how many shapes carry a classification at all —
|
|
rather than the old "has a snippet" fraction. Unclassified is the todo
|
|
(note 2786).
|
|
|
|
None means "nothing to measure" — the owner's keyring serves none of the
|
|
project's bound repos (#2778). That is the ordinary state for a
|
|
forge-less user and every caller treats it as silence, not failure; the
|
|
ledger is untouched in that case. Forge errors (unreachable, bad token)
|
|
RAISE — the two callers are a refresh button and a background task, and
|
|
both want to know.
|
|
|
|
``user_id`` is the project OWNER's id: the cache lives there, and the
|
|
keyring resolved here must be the same one every other read uses.
|
|
"""
|
|
from scribe.services import shape_ledger
|
|
from scribe.services.forge import ForgeError
|
|
|
|
if selector is None:
|
|
selector = await get_forges(user_id, project_id)
|
|
if not selector.configured:
|
|
return None
|
|
|
|
served: list[tuple[str, str]] = []
|
|
recorded = await _recorded_locations(user_id, project_id)
|
|
for key in await keys_for_project(user_id, project_id):
|
|
hit = selector.resolve(key)
|
|
if hit is None:
|
|
continue # bound to a host no connection serves
|
|
forge, api_repo = hit
|
|
ref = await forge.default_branch(api_repo)
|
|
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
|
|
# The head commit is provenance sugar on the ledger rows; failing to
|
|
# learn it must not fail the sync — the ref names the point well
|
|
# enough and the row timestamps carry the when.
|
|
try:
|
|
marker = await forge.latest_commit(api_repo, "", ref) or ref
|
|
except ForgeError:
|
|
marker = ref
|
|
await shape_ledger.sync_repo_shapes(
|
|
project_id, key, shapes, seen_marker=marker
|
|
)
|
|
served.append((key, ref))
|
|
if not served:
|
|
return None
|
|
|
|
await shape_ledger.mark_canonicals(project_id, recorded)
|
|
|
|
# Project-wide readout, deliberately wider than this walk: a second bound
|
|
# repo that was unreachable today still has live rows, and they count.
|
|
rows = await shape_ledger.live_rows(project_id)
|
|
counts = {"canonical": 0, "instance": 0, "variant": 0, "exempt": 0,
|
|
"unclassified": 0}
|
|
for row in rows:
|
|
counts[row.status] = counts.get(row.status, 0) + 1
|
|
by_repo: dict[str, dict[str, int]] = {}
|
|
for row in rows:
|
|
agg = by_repo.setdefault(row.repo_key, {"total": 0, "accounted": 0})
|
|
agg["total"] += 1
|
|
agg["accounted"] += row.status != "unclassified"
|
|
|
|
unclassified = counts.pop("unclassified")
|
|
return {
|
|
"total": len(rows),
|
|
"accounted": len(rows) - unclassified,
|
|
"unclassified": unclassified,
|
|
"counts": counts,
|
|
# Honesty flag, not decoration: every surface that shows the number
|
|
# is expected to carry it through.
|
|
"estimate": True,
|
|
"computed_at": datetime.now(timezone.utc).isoformat(),
|
|
"repos": [
|
|
{"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
|
|
]),
|
|
}
|
|
|
|
|
|
async def refresh_coverage(
|
|
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
|
|
) -> dict | None:
|
|
"""Compute and cache. The only writer of the cache key."""
|
|
coverage = await compute_coverage(user_id, project_id, selector=selector)
|
|
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]
|
|
counts = coverage.get("counts") or {}
|
|
breakdown = " · ".join(
|
|
f"{counts[k]} {k}"
|
|
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 []]
|
|
if gaps:
|
|
line += ", largest: " + ", ".join(gaps)
|
|
return line
|