CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 1m13s
CI & Build / integration (push) Successful in 1m15s
CI & Build / Python tests (push) Successful in 2m8s
CI & Build / Build & push image (push) Successful in 28s
A binding and every row of its project's code-shape ledger are keyed by the repo's remote. A rename on the forge changes the remote, so binding the new name started an empty ledger beside the old one and left every judgment behind. bind_repo now takes moved_from, the old remote. Its binding and the project's ledger rows move to the new key in one transaction, and the reply says how many shapes moved. The move is refused when: - the old name isn't bound to that project; - the new name is already bound; - the project already has a ledger under the new name. Each refusal leaves both names exactly as they were. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
915 lines
42 KiB
Python
915 lines
42 KiB
Python
"""Pattern-library coverage — 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 (grimoire_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 json
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
|
||
from grimoire.services.coverage import (
|
||
ArchiveScan,
|
||
class_references,
|
||
coverage_line,
|
||
scan_archive,
|
||
extract_shapes,
|
||
largest_gaps,
|
||
scannable,
|
||
shapes_from_archive,
|
||
)
|
||
from grimoire.services.shape_ledger import location_covers
|
||
from tests.helpers import ensure_user, forge_tarball
|
||
|
||
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
|
||
|
||
# --- 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")]),
|
||
# --- comment and string spans --------------------------------------------
|
||
# Prose is not code. A wrapped docstring line beginning "class AND the"
|
||
# would otherwise announce a shape called `AND`.
|
||
("docstring-prose",
|
||
'def real_one():\n """Its own text is about the\n'
|
||
' class AND the to_dict, and is a def bar():\n'
|
||
' class Foo: lives here too.\n """\n pass\n',
|
||
[("sym", "real_one")]),
|
||
# An opener with no closer blanks NOTHING: the scan rewinds past it, so a
|
||
# stray marker costs one span rather than the rest of the file.
|
||
("unterminated-docstring",
|
||
'def before():\n """oops, never closed\n\ndef after():\n pass\n',
|
||
[("sym", "before"), ("sym", "after")]),
|
||
# The CSS half of the same rule: a wrapped comment line that
|
||
# happens to begin with a dotted token reads as a selector.
|
||
("css-comment-selector",
|
||
"/* A real base rule, not just descendants: the check reads a\n"
|
||
" .ghost, class that only ever appears as an ancestor */\n.check { }\n",
|
||
[("css", "check")]),
|
||
# A comment holding a WHOLE rule defines nothing, and an unterminated
|
||
# comment does not swallow the file. A phantom class out of a comment
|
||
# would persist with a `used_by` count of zero and inflate unused_css.
|
||
("comment-containing-a-whole-rule",
|
||
"/* .ghost {\n color: red;\n } */\n.real { color: blue; }\n",
|
||
[("css", "real")]),
|
||
("unterminated-block-comment",
|
||
"/* never closed\n.after { color: red; }\n",
|
||
[("css", "after")]),
|
||
# A string that HOLDS a comment marker is not a comment — the case that
|
||
# made the first draft of the scan eat 70 lines of live code.
|
||
("string-holds-a-marker",
|
||
'SAMPLE = "red /* "\ndef after_the_string():\n pass\n',
|
||
[("sym", "after_the_string")]),
|
||
# `#` is the one marker whose meaning is the language\'s: a colour here,
|
||
# a comment two lines down, and the extractor is handed no path.
|
||
("hash-is-a-colour-not-a-comment",
|
||
".a { color: #fff; } /* .ghost,\n class Phantom: */\n.b { }\n",
|
||
[("css", "a"), ("css", "b")]),
|
||
("line-comment-mentioning-a-docstring",
|
||
'def kept():\n pass\n# a stray """ in a comment\n'
|
||
'def also_kept():\n pass\n',
|
||
[("sym", "kept"), ("sym", "also_kept")]),
|
||
]
|
||
|
||
|
||
@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
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[t for _i, t, _e in EXTRACTION_VECTORS],
|
||
ids=[i for i, _t, _e in EXTRACTION_VECTORS],
|
||
)
|
||
def test_the_hook_extractor_runs_and_agrees_line_for_line(text):
|
||
"""The comment at the top of this module has been the only thing holding
|
||
the two extractors together, and a comment cannot fail. This RUNS the
|
||
hook's awk program over the same vectors.
|
||
|
||
The hook emits every definition in source order with no dedup — identity
|
||
there is per payload, not per file — so the comparison de-dupes its
|
||
output before matching, which is the one difference between the two that
|
||
is by design.
|
||
"""
|
||
if shutil.which("awk") is None: # pragma: no cover - env guard
|
||
pytest.skip("awk not available")
|
||
lib = PLUGIN / "hooks" / "grimoire_defs.sh"
|
||
out = subprocess.run(
|
||
["bash", "-c", f'. "{lib}"; grimoire_defs'],
|
||
input=text, capture_output=True, text=True,
|
||
)
|
||
assert out.returncode == 0, out.stderr
|
||
seen: list[tuple[str, str]] = []
|
||
for line in out.stdout.splitlines():
|
||
kind, _, name = line.partition("\t")
|
||
if name and (kind, name) not in seen:
|
||
seen.append((kind, name))
|
||
assert seen == extract_shapes(text)
|
||
|
||
|
||
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 -----------------------------
|
||
|
||
|
||
_tarball = forge_tarball # shared with tests/test_platforms.py
|
||
|
||
|
||
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: template class references — the CSS consumer map ----------------
|
||
|
||
|
||
def test_class_references_reads_vue_static_and_dynamic_forms_only():
|
||
"""A template's class attributes name the classes it consumes: the static
|
||
`class=`, the Vue dynamic object/array/ternary forms (string literals and
|
||
bare object keys), never a selector in <style>, a `class Foo` in
|
||
<script>, a `querySelector('.x')`, or a look-alike attribute."""
|
||
vue = (
|
||
"<template>\n"
|
||
' <div class="card card--wide" :class="{ active: isOpen, \'is-error\': err }">\n'
|
||
' <span :class="[ \'pill\', cond ? \'pill-on\' : \'pill-off\', other ]" />\n'
|
||
' <p class="card" v-bind:class="open ? openCls : \'closed\'">{{ t }}</p>\n'
|
||
' <i data-class="nope" headerClass="nope2" />\n'
|
||
" </div>\n"
|
||
"</template>\n"
|
||
'<script setup lang="ts">\n'
|
||
"class Foo {}\n"
|
||
"const el = document.querySelector('.zap')\n"
|
||
"</script>\n"
|
||
"<style scoped>\n"
|
||
".card { color: red; }\n"
|
||
".zap { color: blue; }\n"
|
||
"</style>\n"
|
||
)
|
||
assert class_references("a/B.vue", vue) == {
|
||
"card": 2, "card--wide": 1, "active": 1, "is-error": 1,
|
||
"pill": 1, "pill-on": 1, "pill-off": 1, "closed": 1,
|
||
}
|
||
|
||
|
||
def test_class_references_reads_framework_transition_names():
|
||
"""A transition `name=` is a class reference: the framework applies
|
||
`.toast-enter-active` and friends at runtime, so a stylesheet that
|
||
defines them is consumed even though no template spells one out.
|
||
Every spelling of the tag counts; a bound `:name` stays unknowable."""
|
||
refs = class_references(
|
||
"a/T.vue",
|
||
'<transition-group name="toast"><div class="toast-item" /></transition-group>',
|
||
)
|
||
for suffix in ("-enter-from", "-enter-active", "-leave-to", "-move"):
|
||
assert refs["toast" + suffix] == 1, suffix
|
||
assert refs["toast-item"] == 1 # the static attribute still counts
|
||
|
||
assert "peek-enter-active" in class_references("a/T.vue", '<Transition name="peek">')
|
||
assert "g-move" in class_references("a/T.vue", '<TransitionGroup name="g">')
|
||
# React's CSSTransition names the same idea with a different suffix set
|
||
react = class_references("a/T.jsx", '<CSSTransition classNames="fade">')
|
||
assert {"fade-enter-active", "fade-exit-active", "fade-exit-done"} <= set(react)
|
||
# A bound name is a variable, not a name we can read
|
||
assert class_references("a/T.vue", '<Transition :name="dyn">') == {}
|
||
|
||
|
||
def test_class_references_reads_a_concatenated_name_as_a_prefix():
|
||
"""`status-${s}` cannot be resolved to one class, but its static head is
|
||
real information: it is emitted as the prefix reference `status-*` so
|
||
the rows it could have built are not reported unused. A head too
|
||
short to mean anything, or a bare separator, says nothing."""
|
||
vue = (
|
||
'<div :class="`status-${task.status}`" />'
|
||
"<span :class=\"['pri-' + p]\" />"
|
||
'<b class="a-" /><u class="-" />'
|
||
)
|
||
assert class_references("a/P.vue", vue) == {"status-*": 1, "pri-*": 1}
|
||
# a server template interpolating into the middle of a name, same reading
|
||
assert class_references("t/p.html", '<i class="card-{{ v }}" />') == {"card-*": 1}
|
||
# an ordinary name never picks up the marker
|
||
assert class_references("a/P.vue", '<div class="page-header" />') == {"page-header": 1}
|
||
|
||
|
||
def test_class_references_reads_react_svelte_and_server_templates():
|
||
tsx = (
|
||
"export function X({ on }: { on: boolean }) {\n"
|
||
' return <button className="btn btn-primary" data-x="y">\n'
|
||
" <i className={on ? 'tab tab-on' : 'tab'} />\n"
|
||
" <b className={`chip ${on ? 'chip-on' : ''} chip-sm`} />\n"
|
||
" <u className={cn({ pill: on, 'pill-off': !on })} />\n"
|
||
" </button>\n"
|
||
"}\n"
|
||
)
|
||
# A template literal's static text counts; its `${…}` hole is unknowable
|
||
# (chip-on sits inside the hole's own ternary and is NOT claimed).
|
||
assert class_references("a/x.tsx", tsx) == {
|
||
"btn": 1, "btn-primary": 1, "tab": 2, "tab-on": 1,
|
||
"chip": 1, "chip-sm": 1, "pill": 1, "pill-off": 1,
|
||
}
|
||
assert class_references("a/y.svelte", '<div class:active={on} class="row">') == {
|
||
"row": 1, "active": 1,
|
||
}
|
||
# A server-side interpolation contributes no token; a literal class inside
|
||
# a template conditional still does.
|
||
html = '<div class="row {{ cls }} col-2 {% if x %}y{% endif %}">'
|
||
assert class_references("t/p.html", html) == {"row": 1, "col-2": 1, "y": 1}
|
||
# Not a template-bearing file: nothing, however it reads.
|
||
assert class_references("a/z.py", 'html = \'<div class="row">\'') == {}
|
||
|
||
|
||
def test_scan_archive_returns_definitions_and_references_from_one_walk():
|
||
tree = dict(TREE)
|
||
tree["web/Card.vue"] = (
|
||
b'<template><div class="btn card">x</div></template>\n'
|
||
b"<style scoped>\n.card {\n color: red;\n}\n</style>\n"
|
||
)
|
||
scan = scan_archive(_tarball(tree))
|
||
assert isinstance(scan, ArchiveScan)
|
||
assert [(d.path, d.kind, d.name) for d in scan.definitions] == TREE_SHAPES + [
|
||
("web/Card.vue", "file", "Card"), # a component is a unit
|
||
("web/Card.vue", "css", "card"),
|
||
]
|
||
# Only files whose markup names a class appear; the .py/.css files don't.
|
||
assert scan.references == {"web/Card.vue": {"btn": 1, "card": 1}}
|
||
assert shapes_from_archive(_tarball(tree)) == [(d.path, d.kind, d.name) for d in scan.definitions]
|
||
|
||
|
||
# --- unit: the covering predicate (lives with the ledger) --------------------
|
||
|
||
|
||
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_type_import_specifiers_are_not_definitions():
|
||
"""`import { type Foo, bar }` is the same two words as `type Foo =`
|
||
and defines nothing; only a `type` line with a declaration after the
|
||
name counts (TS alias, Go/Rust type)."""
|
||
from grimoire.services.coverage import extract_shapes
|
||
src = (
|
||
'import { type DesignSystem, fetchDesignSystems } from "@/api/designSystems";\n'
|
||
'import { type Project } from "./x";\n'
|
||
"type Baz = { a: number };\n"
|
||
"type Wide<T> = T | null;\n"
|
||
"type Point struct {\n\tX int\n}\n"
|
||
)
|
||
assert extract_shapes(src) == [("sym", "Baz"), ("sym", "Wide"), ("sym", "Point")]
|
||
|
||
|
||
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():
|
||
"""The binding names the branch the ledger follows."""
|
||
from grimoire.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_bind_repo_tool_can_move_a_renamed_repo():
|
||
"""A repo renamed on its forge carries its ledger to the new name."""
|
||
from grimoire.mcp.server import build_mcp_server
|
||
tool = build_mcp_server()._tool_manager.get_tool("bind_repo")
|
||
assert "moved_from" in tool.parameters.get("properties", {})
|
||
|
||
|
||
def test_coverage_routes_are_registered():
|
||
from grimoire.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 grimoire.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 — one owner
|
||
keyring holding the mocked Gitea adapter."""
|
||
from grimoire.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 grimoire.models import async_session
|
||
from grimoire.models.project import Project
|
||
from grimoire.services import snippets as svc
|
||
from grimoire.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 grimoire.services.coverage import (
|
||
cached_coverage,
|
||
compute_coverage,
|
||
refresh_coverage,
|
||
)
|
||
|
||
from sqlalchemy import select
|
||
|
||
from grimoire.models import async_session
|
||
from grimoire.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}
|
||
]
|
||
# A first computation has no previous stamp — nothing is "new".
|
||
assert coverage["derive_new"] == {"count": 0, "examples": []}
|
||
|
||
# The walk fed the LEDGER: 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: 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 grimoire.models import async_session
|
||
from grimoire.models.code_shape import CodeShape
|
||
from grimoire.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 grimoire.mcp._context import _user_id_ctx
|
||
from grimoire.mcp.tools.projects import enter_project
|
||
from grimoire.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 raises fixable errors instead of silence: an agent
|
||
mid-task must learn WHY nothing measured, and 'None' would strand it."""
|
||
from grimoire.models import async_session
|
||
from grimoire.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 must exit silently for a forge-less owner — the
|
||
baseline install — and treat a fresh cache as nothing-to-do."""
|
||
from grimoire.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 grimoire.services.coverage import compute_coverage
|
||
from grimoire.services.repo_bindings import set_binding
|
||
|
||
from grimoire.models import async_session
|
||
from grimoire.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
|
||
|
||
|
||
# --- 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 grimoire.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 grimoire.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: 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,
|
||
# which would group every unrelated one-liner as one copy — and a
|
||
# SINGLE declaration is not a shape: 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 {\n color: red;\n margin: 0;\n}\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 grimoire.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
|
||
# A css top copy says what renders it; unused classes
|
||
# join the standing block only when measured (None = no evidence).
|
||
line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [
|
||
{"group": "name:css:error-msg", "label": ".error-msg", "files": 6,
|
||
"consumers": {"count": 6, "paths": ["a.vue"]}}], "unused_css": 3})
|
||
assert "top copy .error-msg ×6 files · used by 6 templates" in line
|
||
assert "3 unused classes" in line
|
||
line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [
|
||
{"group": "name:css:x", "label": ".x", "files": 2,
|
||
"consumers": {"count": 1, "paths": ["a.vue"]}}], "unused_css": None})
|
||
assert "top copy .x ×2 files · used by 1 template" in line and "unused" not in line
|
||
# 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():
|
||
"""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 grimoire.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 grimoire.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():
|
||
"""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 grimoire.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()
|
||
|
||
|
||
def test_svelte_scopes_by_default_and_its_module_script_stays_importable():
|
||
"""Svelte scopes the other way round from Vue — every <style> is the
|
||
component's own unless it opts out, and only <script module> /
|
||
<script context="module"> can be imported elsewhere. Without this a
|
||
Svelte project's every `.card` and `.title` sits in the human todo."""
|
||
from grimoire.services.coverage import extract_definitions, scoped_definitions
|
||
svelte = (
|
||
"<script module lang=\"ts\">\n"
|
||
"export function stageOf(x: number) {\n return x;\n}\n"
|
||
"</script>\n\n"
|
||
"<script lang=\"ts\">\n"
|
||
"function toggle() {\n open = !open;\n}\n"
|
||
"</script>\n\n"
|
||
"<div class=\"card\"></div>\n\n"
|
||
"<style>\n.card {\n padding: 1rem;\n}\n:global(.toast) {\n color: red;\n}\n</style>\n"
|
||
)
|
||
defs = extract_definitions(svelte)
|
||
names = {(d.kind, d.name) for d in defs}
|
||
assert {("sym", "stageOf"), ("sym", "toggle"), ("css", "card")} <= names
|
||
scoped = scoped_definitions("web/src/lib/A.svelte", svelte, defs)
|
||
assert ("sym", "toggle") in scoped and ("css", "card") in scoped
|
||
assert ("sym", "stageOf") not in scoped, "a module script's export is importable"
|
||
assert not any(n == "toast" for _, n in scoped), ":global(...) opts a selector out"
|
||
# The legacy spelling of the module block, and a whole global style block.
|
||
legacy = (
|
||
"<script context=\"module\">\nexport function shared() {\n return 1;\n}\n</script>\n"
|
||
"<style global>\n.page {\n margin: 0;\n}\n</style>\n"
|
||
)
|
||
assert scoped_definitions("web/src/lib/B.svelte", legacy, extract_definitions(legacy)) == set()
|
||
|
||
|
||
def test_the_line_names_the_review_queue_and_the_tool_that_holds_it():
|
||
"""`stamps_to_review` goes uncalled unless something on arrival says
|
||
there is anything to review."""
|
||
from grimoire.services.coverage import coverage_line
|
||
base = {"accounted": 10, "total": 10, "counts": {"exempt": 10},
|
||
"unclassified": 0, "computed_at": "2026-09-30T00:00:00"}
|
||
assert "stamps_to_review" not in coverage_line(base)
|
||
line = coverage_line({**base, "weak_stamps": 132, "incoherent_canons": 3})
|
||
assert line.endswith("; 132 weak stamps · 3 incoherent canons to judge — stamps_to_review")
|
||
assert coverage_line({**base, "weak_stamps": 1}).endswith("; 1 weak stamp to judge — stamps_to_review")
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_binding_ref_is_the_branch_the_ledger_follows(seeded):
|
||
"""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 grimoire.services.coverage import compute_coverage
|
||
from grimoire.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"
|
||
|
||
|
||
|
||
@pytest.mark.parametrize("path,text,unit", [
|
||
# A single-file component: nothing inside carries its name.
|
||
("web/lib/StatusChip.svelte", "<script>\nfunction tone() {\n return 1;\n}\n</script>\n<span class=\"chip\"></span>\n", True),
|
||
("web/views/Card.vue", "<template><div class=\"card\"/></template>\n", True),
|
||
# A TSX component already has its row: the function named after the file.
|
||
("web/Card.tsx", "export function Card() {\n return <div className=\"card\" />;\n}\n", False),
|
||
# A JSX file whose markup names classes but whose component is named otherwise.
|
||
("web/index.jsx", "function App() {\n return <div className=\"app\" />;\n}\n", True),
|
||
# Modules render nothing: their definitions account for them.
|
||
("internal/api/upnext.go", "func stageOf(x int) int {\n\treturn x\n}\n", False),
|
||
("src/app/util.py", "def helper():\n pass\n", False),
|
||
("web/button.css", ".btn {\n color: red;\n}\n", False),
|
||
])
|
||
def test_a_file_is_a_unit_when_it_renders_and_nothing_inside_carries_its_name(path, text, unit):
|
||
"""One structural test, not a framework list."""
|
||
from grimoire.services.coverage import class_references, extract_definitions, is_file_unit
|
||
|
||
assert is_file_unit(path, text, extract_definitions(text), class_references(path, text)) is unit
|
||
|
||
|
||
def test_the_line_says_whether_writers_judged_what_they_wrote():
|
||
"""The write-time figure — silent until the question has
|
||
been put, then turns checked, asked, and asks walked past."""
|
||
from grimoire.services.coverage import coverage_line
|
||
base = {"accounted": 10, "total": 10, "counts": {"exempt": 10},
|
||
"unclassified": 0, "computed_at": "2026-10-01T00:00:00"}
|
||
assert "written-shape check" not in coverage_line(base)
|
||
assert "written-shape check" not in coverage_line(
|
||
{**base, "write_time": {"checked": 0, "asked": 0, "left": 0, "days": 7}})
|
||
line = coverage_line({**base, "write_time": {"checked": 12, "asked": 4, "left": 1, "days": 7}})
|
||
assert line.endswith("; written-shape check (7d): 12 turns checked, 4 asked, 1 left unjudged")
|