Files
FabledScribe/tests/test_pattern_coverage.py
T
bvandeusenandClaude Fable 5 a72605de8f
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
refactor(frontend): the last pay-down, part 1 — three dead views deleted, editor rules shared, .page-container + .fs-input canon, rules-shared.css; a one-declaration CSS body is not a shape (#2903, milestone 299 step 5)
The derive queue said the biggest duplicate families were whole views:
TaskViewerView, UserManagementView and LogsView were imported nowhere — left
behind when tasks moved to the editor and users/logs became SettingsView
tabs. Deleted (rule 22). Note/TaskEditorView carried six identical scoped
rules -> editor-shared.css (the .tag-suggest-row gap the scoped copies
actually rendered wins). Three views wrapped the page under three names ->
.page-container in components.css. Three scoped input recipes -> the design
system fs-input recipe (snippet #2336), verbatim, in components.css; width/
box-sizing stay with the caller. The three rules panes share .pane and the
pane heading via rules-shared.css (the auth-shared pattern, #2852).

Ledger: a single-declaration CSS rule keeps its selector in its fingerprint,
so `color: var(--fs-text-tertiary)` under five different names is no longer
a five-file "identical body" family — the first pay-down found that most of
the 148 dup families were exactly this, and nobody would consolidate them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 14:55:28 -04:00

621 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from tests.helpers import ensure_user
# --- 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_bind_repo_tool_takes_a_ref():
"""#2873: the binding names the branch the ledger follows."""
from scribe.mcp.server import build_mcp_server
tool = build_mcp_server()._tool_manager.get_tool("bind_repo")
assert "ref" in tool.parameters.get("properties", {})
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/<int:project_id>/coverage" in rules
assert "/api/projects/<int:project_id>/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 in ("/api/v1/repos/alice/widget/archive/main.tar.gz",
"/api/v1/repos/alice/widget/archive/dev.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 seeded(_dispose_engine):
"""User + project + binding + two snippets that cover 2 of TREE's 4 shapes."""
from scribe.models import async_session
from scribe.models.project import Project
from scribe.services import snippets as svc
from scribe.services.repo_bindings import set_binding
async with async_session() as s:
user = await ensure_user(s, "coverage_itest")
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, "scoped": 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}
]
# #2899: a first computation has no previous stamp — nothing is "new".
assert coverage["derive_new"] == {"count": 0, "examples": []}
# 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 line.endswith("; 2 unclassified, largest: src")
finally:
_user_id_ctx.reset(token)
@pytest.mark.integration
async def test_explicit_refresh_names_its_failures(seeded):
"""refresh_for_caller (#2802) raises fixable errors instead of silence:
an agent mid-task must learn WHY nothing measured — 'None' is exactly the
stranding the button-only path caused."""
from scribe.models import async_session
from scribe.services.coverage import refresh_for_caller
uid, pid = seeded["uid"], seeded["pid"]
# The owner has no forge connection rows → the error names the fix.
with pytest.raises(ValueError) as err:
await refresh_for_caller(uid, pid)
assert "Git Forges" in str(err.value)
# A stranger gets not-found/no-write, never a measurement.
async with async_session() as s:
other = await ensure_user(s, "coverage_outsider")
other_id = other.id
await s.commit()
with pytest.raises(ValueError) as err:
await refresh_for_caller(other_id, pid)
assert "no write access" in str(err.value)
@pytest.mark.integration
async def test_background_seed_is_quiet_without_a_forge(seeded):
"""refresh_if_stale (#2802) must exit silently for a forge-less owner —
rule #115's baseline — and treat a fresh cache as nothing-to-do."""
from scribe.services.coverage import refresh_coverage, refresh_if_stale
uid, pid = seeded["uid"], seeded["pid"]
# Absent cache + no forge rows: returns without raising, writes nothing.
await refresh_if_stale(uid, pid)
# Fresh cache: returns before ever consulting the keyring.
stored = await refresh_coverage(uid, pid, selector=_selector(_tarball(TREE)))
await refresh_if_stale(uid, pid, cached=stored)
@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
# --- #2792: fingerprints and the proposer's readout --------------------------
def test_extract_definitions_fingerprints_each_block():
"""The block rule across the language families the extractor knows: a
Python def ends at the next top-level statement, a braces/CSS block keeps
its closer, and comments/decorators don't move the hash."""
from scribe.services.coverage import extract_definitions
text = (
"import os\n\n"
"def a(x):\n # comment\n return x + 1\n\n\n"
"class B:\n def m(self):\n return 2\n\n"
".btn {\n color: red;\n}\n"
"export const f = (x) => {\n return x;\n};\n"
)
defs = {d.name: d for d in extract_definitions(text)}
assert set(defs) == {"a", "B", "m", "btn", "f"}
assert defs["a"].signature == "def a(x):"
assert defs["a"].body.startswith("def a(x):\n # comment\n return x + 1")
assert "class B" not in defs["a"].body
assert defs["B"].body.rstrip().endswith("return 2")
assert defs["btn"].body == ".btn {\n color: red;\n}"
assert defs["f"].body == "export const f = (x) => {\n return x;\n};"
assert all(len(d.body_sha) == 16 for d in defs.values())
# Comment changes don't change what the shape IS; code changes do.
again = {d.name: d for d in extract_definitions(text.replace("# comment", "# other"))}
assert again["a"].body_sha == defs["a"].body_sha
changed = {d.name: d for d in extract_definitions(text.replace("x + 1", "x + 2"))}
assert changed["a"].body_sha != defs["a"].body_sha
# And the identity view is unchanged for the hook mirror.
from scribe.services.coverage import extract_shapes
assert extract_shapes(text) == [(d.kind, d.name) for d in extract_definitions(text)]
# A CSS rule's fingerprint is its declarations (#2872): the same body
# under another selector is the same shape to the derive grouping.
css = ".closed-msg {\n text-align: center;\n padding: 0.5rem 0;\n}\n.error-block {\n text-align: center;\n padding: 0.5rem 0;\n}\n.other {\n text-align: left;\n}\n"
d = {x.name: x for x in extract_definitions(css)}
assert d["closed-msg"].body_sha == d["error-block"].body_sha != d["other"].body_sha
# One-line rules hash their own declarations — never the empty string
# (first deploy grouped 68 unrelated one-liners as one copy) — and a
# SINGLE declaration is not a shape (#2903): it keeps its selector in the
# hash, so `.a { color: red }` groups only with another `.a`, never with
# `.b { color: red }`. Two declarations and up stay selector-agnostic.
one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.a {\n color: red;\n}\n"
e = {x.name: x for x in extract_definitions(one)}
import hashlib
assert e["a"].body_sha != e["b"].body_sha != e["c"].body_sha
assert e["a"].body_sha != hashlib.sha1(b"").hexdigest()[:16]
two = ".a { color: red; margin: 0; }\n.b {\n color: red;\n margin: 0;\n}\n"
f = {x.name: x for x in extract_definitions(two)}
assert f["a"].body_sha == f["b"].body_sha
def test_coverage_line_names_the_proposers_standing():
from scribe.services.coverage import coverage_line
base = {
"total": 100, "accounted": 10, "unclassified": 90,
"counts": {"canonical": 10, "instance": 0, "variant": 0, "exempt": 0},
"computed_at": "2026-08-21T00:00:00+00:00",
"largest_gaps": [{"dir": "src", "unclassified": 90, "total": 90}],
}
assert coverage_line(base).endswith("; 90 unclassified, largest: src")
line = coverage_line({**base, "proposed": 40, "derive_groups": [{"group": "a"}, {"group": "b"}]})
assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line
line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]})
assert "(1 derive group)" in line
# #2874: the next action on the line — biggest canon queue, widest copy.
line = coverage_line({
**base, "proposed": 40, "top_canon": {"snippet_id": 2844, "count": 78},
"derive_groups": [{"group": "dup:abc", "label": "closed-msg (identical body)", "files": 3}],
})
assert "top canon #2844 ×78" in line and "top copy closed-msg (identical body) ×3 files" in line
def test_coverage_line_shows_standing_work_even_with_nothing_unclassified():
"""#2899: since the scoped bucket a ledger can be fully accounted and
still carry derive groups / proposals / divergence — the line names
them as `standing:` instead of hiding them behind the todo count, and
names the drift since the previous refresh first-copy-first."""
from scribe.services.coverage import coverage_line
base = {
"total": 4693, "accounted": 4693, "unclassified": 0,
"counts": {"canonical": 37, "instance": 977, "variant": 73, "exempt": 1797, "scoped": 1809},
"computed_at": "2026-08-22T00:00:00+00:00", "largest_gaps": [],
}
quiet = coverage_line(base)
assert "unclassified" not in quiet and "standing" not in quiet
line = coverage_line({
**base,
"derive_groups": [{"group": "dup:abc", "label": "log-empty (identical body)", "files": 4}],
"derive_new": {"count": 2, "examples": [
{"label": ".error-msg", "path": "frontend/src/components/InceptionCard.vue", "group": "dup:9f0"},
{"label": ".error-msg", "path": "frontend/src/components/Other.vue", "group": "dup:9f0"},
]},
"divergent": 1,
})
assert "; standing: 1 derive group, +2 new copies since last refresh: .error-msg in " \
"frontend/src/components/InceptionCard.vue, 1 DIVERGENT, top copy log-empty (identical body) ×4 files" in line
assert "unclassified" not in line
# One copy reads singular; with a todo the block keeps its old place.
one = coverage_line({**base, "derive_new": {"count": 1, "examples": []}})
assert one.endswith("; standing: +1 new copy since last refresh")
todo = coverage_line({**base, "unclassified": 3, "accounted": 4690, "proposed": 2,
"derive_new": {"count": 1, "examples": [{"label": "x", "path": "a.py"}]},
"largest_gaps": [{"dir": "src", "unclassified": 3, "total": 9}]})
assert "; 3 unclassified (2 proposed, +1 new copy since last refresh: x in a.py), largest: src" in todo
def test_coverage_line_names_divergence_and_recheck():
from scribe.services.coverage import coverage_line
base = {
"total": 100, "accounted": 40, "unclassified": 60,
"counts": {"canonical": 10, "instance": 30, "variant": 0, "exempt": 0},
"computed_at": "2026-08-21T00:00:00+00:00",
"largest_gaps": [{"dir": "src", "unclassified": 60, "total": 60}],
}
line = coverage_line({**base, "divergent": 2, "recheck": 1, "proposed": 5})
assert "; 60 unclassified (5 proposed, 2 DIVERGENT), largest: src" in line
assert line.endswith("; 1 judged shape changed since judged — recheck")
assert "DIVERGENT" not in coverage_line(base)
assert "recheck" not in coverage_line(base)
def test_scoped_definitions_are_vue_script_setup_and_scoped_style_only():
"""#2869: one-offs by construction — every sym in a .vue and every css
rule inside <style scoped>; an unscoped <style> block and non-.vue files
stay ordinary."""
from scribe.services.coverage import extract_definitions, scoped_definitions
vue = (
"<script setup lang=\"ts\">\n"
"function load() {\n return 1;\n}\n"
"const save = async () => {\n return 2;\n};\n"
"</script>\n\n"
"<template><div class=\"card\"/></template>\n\n"
"<style scoped>\n.card {\n padding: 1rem;\n}\n.title {\n margin: 0;\n}\n</style>\n"
"<style>\n.global-toast {\n color: red;\n}\n</style>\n"
)
defs = extract_definitions(vue)
names = {(d.kind, d.name) for d in defs}
assert {("sym", "load"), ("sym", "save"), ("css", "card"), ("css", "title"), ("css", "global-toast")} <= names
scoped = scoped_definitions("frontend/src/views/A.vue", vue, defs)
assert scoped == {("sym", "load"), ("sym", "save"), ("css", "card"), ("css", "title")}
# Definitions know their line, which is what the scoped-style range uses.
assert next(d for d in defs if d.name == "card").line > next(d for d in defs if d.name == "save").line
# Not a .vue: nothing is scoped, whatever it contains.
assert scoped_definitions("frontend/src/assets/components.css", ".card {\n x: 1;\n}\n",
extract_definitions(".card {\n x: 1;\n}\n")) == set()
assert scoped_definitions("src/a.py", "def load():\n pass\n", extract_definitions("def load():\n pass\n")) == set()
@pytest.mark.integration
async def test_binding_ref_is_the_branch_the_ledger_follows(seeded):
"""#2873: a binding that names a ref is read at that ref (not the forge's
default branch); "" clears it; None on a re-bind leaves it standing."""
from scribe.services.coverage import compute_coverage
from scribe.services.repo_bindings import bindings_for_project, set_binding
uid, pid = seeded["uid"], seeded["pid"]
b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "dev")
assert b.ref == "dev"
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
assert coverage["repos"][0]["ref"] == "dev"
# A re-bind without a ref keeps it; "" clears it back to the default branch.
b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid)
assert b.ref == "dev"
b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "")
assert b.ref is None
assert [x.ref for x in await bindings_for_project(uid, pid)] == [None]
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
assert coverage["repos"][0]["ref"] == "main"