"""Pattern-library coverage (#2692) — the extractor that mirrors the hook, the shape/record matcher, and the end-to-end measurement on real Postgres. The extractor here and the hook's awk program (scribe_prior_art.sh ARM 1) must agree on what counts as "a definition" — the metric and the write-path backstop are two views of the same doctrine. The EXTRACTION_VECTORS below deliberately reuse the definitions test_write_path_trigger stages for the hook; extending one detector means extending both, and this comment is the tripwire. """ import io import json import tarfile import pytest import pytest_asyncio from scribe.services.coverage import ( coverage_line, extract_shapes, largest_gaps, scannable, shapes_from_archive, ) from scribe.services.shape_ledger import location_covers # --- unit: the definition extractor (shared vectors with the hook) ----------- EXTRACTION_VECTORS = [ # (id, source text, expected (kind, name) list) ("python", "def make_app():\n pass\nclass Config:\n pass\n", [("sym", "make_app"), ("sym", "Config")]), ("python-dunder-skip", "class C:\n def __init__(self):\n pass\n", [("sym", "C")]), ("go-func", "func Resolve(x int) error {\n\treturn nil\n}\n", [("sym", "Resolve")]), ("go-method", "func (s *Scanner) Resolve(x int) error {\n\treturn nil\n}\n", [("sym", "Resolve")]), ("kotlin-fun", "suspend fun refreshQueue(id: Long) {\n}\n", [("sym", "refreshQueue")]), ("rust-fn", "pub async fn fetch_all() -> u32 {\n 0\n}\n", [("sym", "fetch_all")]), ("go-type", "type ForgeAdapter struct {\n\tname string\n}\n", [("sym", "ForgeAdapter")]), ("rust-pub-crate", "pub(crate) struct Widget {}\n", [("sym", "Widget")]), ("js-export-default", "export default function App() {}\n", [("sym", "App")]), ("js-arrow", "const useThing = (id) => id;\nlet fetcher = async () => 0;\n", [("sym", "useThing"), ("sym", "fetcher")]), # Every selector line in a group counts — .btn-ghost, and .btn-text { } # both announce a class, exactly as the hook's awk sees them. ("css", ".btn-primary {\n color: red;\n}\n.btn-ghost,\n.btn-text { }\n", [("css", "btn-primary"), ("css", "btn-ghost"), ("css", "btn-text")]), # Call sites, imports, and impl blocks are NOT definitions — matching # them would drown the metric exactly as it would drown the hook. ("non-definitions", "make_app()\nimpl Widget {\nreturn fetch_all\nimport os\nx = 1\n", []), ("dedup-within-file", "def f():\n pass\ndef f():\n pass\n", [("sym", "f")]), ] @pytest.mark.parametrize( ("text", "expected"), [(t, e) for _i, t, e in EXTRACTION_VECTORS], ids=[i for i, _t, _e in EXTRACTION_VECTORS], ) def test_extractor_agrees_with_the_hook_on_what_defines(text, expected): assert extract_shapes(text) == expected def test_scannable_gates_prose_vendored_and_sourcemaps(): assert scannable("src/app.py") assert scannable("web/button.css") assert scannable(".gitea/workflows/ci.yml") # config IS worth recording assert not scannable("README.md") assert not scannable("dist/bundle.js.map") assert not scannable("node_modules/x/index.js") assert not scannable("web/node_modules/y/util.ts") # A FILE named like a skip-dir is not a directory hit. assert scannable("src/vendor.py") # --- unit: reading shapes out of a forge tarball ----------------------------- def _tarball(files: dict[str, bytes], top: str = "widget") -> bytes: buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: for path, data in files.items(): info = tarfile.TarInfo(f"{top}/{path}") info.size = len(data) tar.addfile(info, io.BytesIO(data)) return buf.getvalue() TREE = { "src/app.py": b"def make_app():\n pass\nclass Config:\n def __init__(self):\n pass\n", "src/util.py": b"def helper():\n pass\n", "web/button.css": b".btn {\n color: red;\n}\n", "README.md": b"def not_code(): pass\n", "node_modules/x/index.js": b"function vendored() {}\n", "data.bin": b"\xff\xfe\x00\x01", } # What TREE holds once the gates run: 4 shapes, none from the skipped files. TREE_SHAPES = [ ("src/app.py", "sym", "make_app"), ("src/app.py", "sym", "Config"), ("src/util.py", "sym", "helper"), ("web/button.css", "css", "btn"), ] def test_shapes_from_archive_strips_the_wrapper_and_gates_files(): assert shapes_from_archive(_tarball(TREE)) == TREE_SHAPES # --- unit: the covering predicate (lives with the ledger since #2788) -------- def test_location_covers_by_exact_path_dir_prefix_and_css_dot(): assert location_covers("src/app.py", "make_app", "src/app.py", "make_app") # dir prefix + css dot normalization assert location_covers("web", ".btn", "web/button.css", "btn") assert not location_covers("src/app.py", "make_app", "src/util.py", "make_app") def test_a_symbol_less_record_covers_nothing(): """A whole-file snippet makes no claim about any particular definition inside it — crediting all of them would inflate the number for free.""" assert not location_covers("src/app.py", "", "src/app.py", "make_app") def test_no_prefix_bleed_between_sibling_directories(): assert not location_covers("src/lib", "helper", "src/library/x.py", "helper") def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs(): accounted = [ ("src/app.py", "sym", "make_app", True), ("src/app.py", "sym", "Config", False), ("src/util.py", "sym", "helper", False), ("web/button.css", "css", "btn", True), ] gaps = largest_gaps(accounted) assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}] def test_coverage_line_is_evidence_carrying_and_labeled_estimate(): line = coverage_line({ "total": 4573, "accounted": 3100, "unclassified": 1473, "counts": {"canonical": 12, "instance": 2900, "variant": 0, "exempt": 188}, "estimate": True, "computed_at": "2026-08-16T12:00:00+00:00", "largest_gaps": [ {"dir": "internal/api", "unclassified": 40, "total": 60}, {"dir": "web/src/components", "unclassified": 25, "total": 30}, ], }) assert "3100/4573 shapes accounted for" in line assert "12 canonical · 2900 instance · 188 exempt" in line # zero variant elided assert "estimate" in line assert "2026-08-16" in line assert "1473 unclassified" in line assert "internal/api, web/src/components" in line def test_coverage_routes_are_registered(): from scribe.app import create_app rules = {r.rule for r in create_app().url_map.iter_rules()} assert "/api/projects//coverage" in rules assert "/api/projects//coverage/refresh" in rules # --- integration: the measurement end to end on real Postgres ---------------- def _forge(tar_bytes: bytes): import httpx from scribe.services.forge import GiteaForge def handler(request: httpx.Request) -> httpx.Response: path = request.url.path if path == "/api/v1/repos/alice/widget": return httpx.Response(200, json={"default_branch": "main"}) if path == "/api/v1/repos/alice/widget/archive/main.tar.gz": return httpx.Response(200, content=tar_bytes) return httpx.Response(404, json={"message": "not found"}) return GiteaForge( "https://git.example.com", "tok", transport=httpx.MockTransport(handler) ) def _selector(tar_bytes: bytes): """The keyring shape compute_coverage consumes since #2778 — one owner keyring holding the mocked Gitea adapter.""" from scribe.services.forge import ForgeSelector return ForgeSelector((_forge(tar_bytes),)) @pytest_asyncio.fixture async def _dispose_engine(): from scribe.models import engine yield await engine.dispose() @pytest_asyncio.fixture async def seeded(_dispose_engine): """User + project + binding + two snippets that cover 2 of TREE's 4 shapes.""" from sqlalchemy import select from scribe.models import async_session from scribe.models.project import Project from scribe.models.user import User from scribe.services import snippets as svc from scribe.services.repo_bindings import set_binding async with async_session() as s: user = ( await s.execute(select(User).where(User.username == "coverage_itest")) ).scalar_one_or_none() if user is None: user = User(username="coverage_itest") s.add(user) await s.flush() project = Project(user_id=user.id, title="Widget") s.add(project) await s.flush() uid, pid = user.id, project.id await s.commit() await set_binding(uid, "https://git.example.com/alice/widget.git", pid) await svc.create_snippet( uid, name="cov_make_app", code="def make_app():\n pass\n", language="python", repo="Widget", path="src/app.py", symbol="make_app", project_id=pid, ) await svc.create_snippet( uid, name="cov_btn", code=".btn {\n color: red;\n}\n", language="css", repo="Widget", path="web", symbol=".btn", project_id=pid, ) return {"uid": uid, "pid": pid} @pytest.mark.integration async def test_coverage_measures_the_tree_exactly_and_caches(seeded): from scribe.services.coverage import ( cached_coverage, compute_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"] selector = _selector(_tarball(TREE)) coverage = await compute_coverage(uid, pid, selector=selector) assert coverage is not None assert coverage["total"] == 4 assert coverage["accounted"] == 2 assert coverage["unclassified"] == 2 assert coverage["counts"] == { "canonical": 2, "instance": 0, "variant": 0, "exempt": 0, } assert coverage["estimate"] is True assert coverage["repos"] == [{ "repo": "git.example.com/alice/widget", "ref": "main", "total": 4, "accounted": 2, }] assert coverage["largest_gaps"] == [ {"dir": "src", "unclassified": 2, "total": 3} ] # The walk fed the LEDGER (#2788): every extracted shape has a row, the # snippet reference locations are mechanically stamped canonical WITH # their snippet id, and the rest sit in the todo state. async with async_session() as s: rows = (await s.execute( select(CodeShape).where(CodeShape.project_id == pid) )).scalars().all() by_symbol = {r.symbol: r for r in rows} assert set(by_symbol) == {"make_app", "Config", "helper", "btn"} assert by_symbol["make_app"].status == "canonical" assert by_symbol["make_app"].snippet_id is not None assert by_symbol["make_app"].classified_by == "mechanical" assert by_symbol["btn"].status == "canonical" assert by_symbol["Config"].status == "unclassified" assert by_symbol["helper"].status == "unclassified" assert all(r.vanished_at is None for r in rows) assert all(r.first_seen_commit for r in rows) # ref at minimum # Idempotence: a second walk changes nothing about the readout. again = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) assert (again["total"], again["accounted"]) == (4, 2) # Nothing computed → nothing cached; refresh writes; the cache reads back # byte-equal, because enter_project will serve exactly this. assert await cached_coverage(uid, pid) is None stored = await refresh_coverage(uid, pid, selector=selector) assert (await cached_coverage(uid, pid)) == json.loads(json.dumps(stored)) @pytest.mark.integration async def test_ledger_keeps_judgments_and_stamps_vanished_shapes(seeded): """The two survival rules (#2788): an agent's classification outlives recompute, and a shape that leaves the tree is stamped vanished — kept for history, dropped from the readout.""" from datetime import datetime, timezone from sqlalchemy import select from scribe.models import async_session from scribe.models.code_shape import CodeShape from scribe.services.coverage import compute_coverage uid, pid = seeded["uid"], seeded["pid"] await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) # An agent judges `helper` a deliberate one-off. async with async_session() as s: helper = (await s.execute(select(CodeShape).where( CodeShape.project_id == pid, CodeShape.symbol == "helper", ))).scalar_one() helper.status = "exempt" helper.reason = "test scaffolding, deliberately local" helper.classified_by = "agent" helper.classified_at = datetime.now(timezone.utc) await s.commit() # The tree moves on: util.py (helper) is gone entirely, app.py loses # nothing. The judgment on `helper` must survive AS HISTORY (vanished, # still exempt), never be reset by the sync. smaller = {k: v for k, v in TREE.items() if k != "src/util.py"} coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(smaller))) assert coverage["total"] == 3 # helper's row left the readout assert coverage["accounted"] == 2 assert coverage["counts"]["exempt"] == 0 # vanished rows don't count async with async_session() as s: helper = (await s.execute(select(CodeShape).where( CodeShape.project_id == pid, CodeShape.symbol == "helper", ))).scalar_one() assert helper.vanished_at is not None assert helper.status == "exempt" # the judgment is history, kept assert helper.reason == "test scaffolding, deliberately local" # And it returns: the shape reappearing clears the stamp, judgment intact. coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) assert coverage["total"] == 4 assert coverage["accounted"] == 3 # exempt counts as accounted again assert coverage["counts"]["exempt"] == 1 @pytest.mark.integration async def test_enter_project_surfaces_the_line_only_once_computed(seeded): from scribe.mcp._context import _user_id_ctx from scribe.mcp.tools.projects import enter_project from scribe.services.coverage import refresh_coverage uid, pid = seeded["uid"], seeded["pid"] token = _user_id_ctx.set(uid) try: # Forge-less / never-computed instance: the key is present, null, and # nothing else about the response changes. before = await enter_project(project_id=pid) assert before["pattern_coverage"] is None await refresh_coverage(uid, pid, selector=_selector(_tarball(TREE))) after = await enter_project(project_id=pid) line = after["pattern_coverage"] assert line.startswith( "shape accounting: 2/4 shapes accounted for — 2 canonical " "(estimate, computed " ) assert "2 unclassified, largest: src" in line assert line.endswith("; largest gaps: src") finally: _user_id_ctx.reset(token) @pytest.mark.integration async def test_unservable_binding_measures_nothing(seeded): """A project bound only to a host the forge doesn't serve returns None — the same silence as no forge at all, never an error.""" from scribe.services.coverage import compute_coverage from scribe.services.repo_bindings import set_binding from scribe.models import async_session from scribe.models.project import Project uid = seeded["uid"] async with async_session() as s: other = Project(user_id=uid, title="Elsewhere") s.add(other) await s.flush() other_pid = other.id await s.commit() await set_binding(uid, "https://github.com/somebody/else.git", other_pid) assert await compute_coverage(uid, other_pid, selector=_selector(_tarball(TREE))) is None