CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 10s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 23s
Paying down #2962 measured `flag="unused-css"` against a hand audit and found it had a permanent false-positive floor: roughly sixty classes it called unused are alive and always would be, because two ordinary authoring forms produce names no reader of `class=` attributes can see. A flag whose list you cannot act on line by line is worse than no flag — act on it and you delete live UI. - A transition `name=` IS a class reference. `<Transition name="toast">` makes Vue apply `.toast-enter-active` and its siblings at runtime, and React's `<CSSTransition classNames="fade">` does the same with a different suffix set. Every spelling of the tag is read (`Transition`, `TransitionGroup`, `transition-group`), and the emitted suffix set is the union of Vue 3, Vue 2 and React: naming a class no rule defines costs nothing, since it resolves to no row. A bound `:name` stays unknowable. - A concatenated name contributes its static head as a PREFIX reference. `` `status-${s}` ``, `'pri-' + p` and `class="card-{{ v }}"` all leave a head behind once the hole is blanked — and `_CLASS_TOKEN_RE` accepts a trailing hyphen, so until now the extractor emitted a junk token `"status-"` that matched nothing. It is now `status-*`, and resolve_consumers credits every row whose symbol starts with that head, each under the same own-file-else-fan-out rule as an exact token. `*` cannot occur in a class token, so the marker rides the existing dict[str, int] with no schema change. A head shorter than two characters says nothing and is dropped. Crediting every candidate row is the honest reading: the template genuinely does not say which one it built, and the alternative is reporting live rules as dead. What the map still cannot see is a name assembled in a script — `classList.add` — which stays deliberately out of scope; the skill, the `flag=` docstring and the refresh payload docs all say so now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
632 lines
34 KiB
Python
632 lines
34 KiB
Python
"""The shape ledger's schema contract (#2787, milestone 294, note 2786).
|
|
|
|
Step 1 pins the model: identity, the classification vocabulary, and the
|
|
token-free serialisation. The sync pass (step 2) and the classification
|
|
surface (step 3) grow their tests here; DB-backed behavior lands in the
|
|
integration lane once there is behavior to exercise.
|
|
"""
|
|
import pytest
|
|
|
|
from scribe.models import Base
|
|
from scribe.models.code_shape import SHAPE_CLASSIFIERS, SHAPE_STATUSES, CodeShape
|
|
|
|
|
|
def test_identity_is_project_repo_path_symbol_kind():
|
|
"""Kind is part of identity on purpose: one file can define `.foo` (css)
|
|
and `foo` (sym) as distinct shapes — the extractor emits both."""
|
|
table = Base.metadata.tables["code_shapes"]
|
|
unique = next(
|
|
c for c in table.constraints
|
|
if getattr(c, "name", "") == "uq_code_shapes_identity"
|
|
)
|
|
assert [c.name for c in unique.columns] == [
|
|
"project_id", "repo_key", "path", "symbol", "kind",
|
|
]
|
|
|
|
|
|
def test_the_todo_state_is_the_default():
|
|
"""A shape nobody has judged yet must read `unclassified` — the ledger's
|
|
todo list — never silently look classified."""
|
|
assert CodeShape.__table__.c.status.default.arg == "unclassified"
|
|
assert "unclassified" in SHAPE_STATUSES
|
|
assert set(SHAPE_STATUSES) == {
|
|
"canonical", "instance", "variant", "exempt", "scoped", "unclassified",
|
|
}
|
|
assert set(SHAPE_CLASSIFIERS) == {
|
|
"agent", "audit", "hook", "mechanical", "import",
|
|
}
|
|
|
|
|
|
def test_snippet_reference_survives_snippet_deletion_as_null():
|
|
"""SET NULL, not CASCADE: a deleted snippet must not silently erase the
|
|
accounting rows that pointed at it — the sync pass re-files them as
|
|
unclassified so they rejoin the todo."""
|
|
fk = next(iter(CodeShape.__table__.c.snippet_id.foreign_keys))
|
|
assert fk.ondelete == "SET NULL"
|
|
assert fk.column.table.name == "notes"
|
|
|
|
|
|
def test_status_queries_have_an_index():
|
|
"""list_shapes(status=unclassified) is THE todo query (step 3) — it must
|
|
not degrade into a project-wide scan as ledgers reach thousands of rows."""
|
|
names = {ix.name for ix in CodeShape.__table__.indexes}
|
|
assert "ix_code_shapes_project_status" in names
|
|
assert "ix_code_shapes_snippet" in names
|
|
|
|
|
|
# --- step 3: the classification batch validator (pure, checked before ACL) ---
|
|
|
|
|
|
def test_batch_validation_names_the_failing_item():
|
|
from scribe.services.shape_ledger import validate_classifications as v
|
|
|
|
ok = {"path": "src/a.py", "symbol": "f", "status": "exempt", "reason": "one-off"}
|
|
assert v([ok]) is None
|
|
assert "empty" in v([])
|
|
assert "classifications[1]" in v([ok, {"symbol": "f", "status": "exempt"}])
|
|
assert "unknown status" in v([{**ok, "status": "covered"}])
|
|
# A judgment that references canon must name the canon...
|
|
assert "needs snippet_id" in v(
|
|
[{"path": "a", "symbol": "f", "status": "instance"}]
|
|
)
|
|
# ...and a departure/exemption must carry its why — the why IS the record.
|
|
assert "needs a reason" in v(
|
|
[{"path": "a", "symbol": "f", "status": "variant", "snippet_id": 3}]
|
|
)
|
|
assert "needs a reason" in v([{"path": "a", "symbol": "f", "status": "exempt"}])
|
|
# Withdrawing a judgment needs neither target nor reason.
|
|
assert v([{"path": "a", "symbol": "f", "status": "unclassified"}]) is None
|
|
|
|
|
|
def test_classify_and_list_are_mounted_as_mcp_tools():
|
|
from scribe.mcp.server import build_mcp_server
|
|
|
|
mcp = build_mcp_server()
|
|
for name in ("classify_shapes", "list_shapes", "refresh_pattern_coverage"):
|
|
assert mcp._tool_manager.get_tool(name) is not None
|
|
|
|
|
|
# --- step 5: the write-path feed's evidence tests (pure) ---------------------
|
|
|
|
|
|
def test_symbol_reference_is_word_bounded_and_kind_aware():
|
|
from scribe.services.shape_ledger import references_symbol as ref
|
|
|
|
code = "const ok = await confirmed({ title: 'x' });\nif (!ok) return;"
|
|
assert ref(code, "confirmed", "sym")
|
|
assert not ref(code, "confirm", "sym") # prefix never claims the call
|
|
assert not ref("", "confirmed", "sym")
|
|
assert not ref(code, "", "sym")
|
|
# CSS: the class as a selector or inside a class attribute; dashes are part
|
|
# of the name, so `btn` must not claim `btn-primary`.
|
|
html = '<button class="btn btn-primary">Go</button>'
|
|
assert ref(html, ".btn-primary", "css")
|
|
assert ref(html, "btn-primary", "css")
|
|
assert ref(".btn-primary { color: red }", ".btn-primary", "css")
|
|
assert not ref('<button class="btn-primary">', ".btn", "css")
|
|
assert ref('<button class="btn-primary btn">', ".btn", "css")
|
|
|
|
|
|
def test_snippet_kind_reads_the_symbol_then_the_language():
|
|
from scribe.services.shape_ledger import snippet_kind
|
|
|
|
assert snippet_kind(".btn-primary", "css") == "css"
|
|
assert snippet_kind(".btn-primary", "") == "css"
|
|
assert snippet_kind("confirmed", "typescript") == "sym"
|
|
assert snippet_kind("", "scss") == "css" # whole-stylesheet record
|
|
assert snippet_kind("", "python") == "sym"
|
|
|
|
|
|
def test_route_shapes_param_parses_capped_and_deduped():
|
|
from scribe.routes.plugin import _SHAPES_CAP, _parse_shapes
|
|
|
|
assert _parse_shapes("css:btn-primary,sym:onTrash") == [
|
|
("css", "btn-primary"), ("sym", "onTrash"),
|
|
]
|
|
assert _parse_shapes(" sym:a , sym:a ,bogus:x,sym:,:,") == [("sym", "a")]
|
|
assert _parse_shapes("") == []
|
|
many = ",".join(f"sym:f{i}" for i in range(40))
|
|
assert len(_parse_shapes(many)) == _SHAPES_CAP
|
|
|
|
|
|
def test_hook_is_a_server_internal_classifier():
|
|
"""`hook` is in the status vocabulary but NOT a via a caller may claim —
|
|
a classify_shapes call saying via="hook" would launder judgment as
|
|
evidence (the reverse of the stamping rule's point)."""
|
|
from scribe.services.shape_ledger import _CALLER_VIAS
|
|
|
|
assert "hook" in SHAPE_CLASSIFIERS
|
|
assert "hook" not in _CALLER_VIAS
|
|
|
|
|
|
# --- step 6: the mechanical proposer (pure) ---------------------------------
|
|
|
|
|
|
def test_proposal_columns_and_vocabulary_are_pinned():
|
|
"""Fingerprints + the proposer's standing suggestion live on the row; the
|
|
basis vocabulary is fixed, with `derive` the odd one out (a group, not a
|
|
snippet)."""
|
|
from scribe.models.code_shape import PROPOSAL_BASES
|
|
|
|
cols = CodeShape.__table__.c
|
|
for name in ("signature", "body_sha", "proposed_snippet_id", "proposal_basis",
|
|
"proposal_score", "proposal_group", "proposed_at", "proposed_sha"):
|
|
assert name in cols, name
|
|
fk = next(iter(cols.proposed_snippet_id.foreign_keys))
|
|
assert fk.ondelete == "SET NULL" and fk.column.table.name == "notes"
|
|
assert "ix_code_shapes_proposed" in {ix.name for ix in CodeShape.__table__.indexes}
|
|
assert PROPOSAL_BASES == ("symbol", "text", "reference", "signature", "semantic", "derive")
|
|
|
|
|
|
def test_row_proposal_property_is_one_object_or_none():
|
|
row = CodeShape(project_id=1, repo_key="r", path="a.py", symbol="f", kind="sym")
|
|
assert row.proposal is None
|
|
row.proposed_snippet_id, row.proposal_basis, row.proposal_score = 9, "symbol", 1.0
|
|
assert row.proposal == {"basis": "symbol", "score": 1.0, "snippet_id": 9}
|
|
row.proposed_snippet_id = None
|
|
row.proposal_basis, row.proposal_group, row.proposal_score = "derive", "dup:abc", 3.0
|
|
assert row.proposal == {"basis": "derive", "score": 3.0, "group": "dup:abc"}
|
|
|
|
|
|
def test_signature_similarity_blanks_the_names():
|
|
from scribe.services.shape_ledger import signature_similarity as sim
|
|
|
|
a = "def move_event(project_id: int, event_id: int, after_id: int | None):"
|
|
b = "def move_beat(project_id: int, beat_id: int, after_id: int | None):"
|
|
assert sim(a, "move_event", b, "move_beat") > 0.85
|
|
assert sim(a, "move_event", "def export_pdf(manuscript, design, fonts):", "export_pdf") < 0.6
|
|
assert sim("", "x", b, "move_beat") == 0.0
|
|
# Trivial signatures resemble everything and mean nothing — floored out.
|
|
assert sim("def helper(x):", "helper", "def make_app():", "make_app") == 0.0
|
|
|
|
|
|
def test_text_containment_is_whitespace_insensitive_with_a_floor():
|
|
from scribe.services.shape_ledger import text_contains
|
|
|
|
code = "const ok = await confirmed({ title: 'Delete?', confirmLabel: 'Delete' });\nif (!ok) return;"
|
|
body = "async function onDelete() {\n const ok = await confirmed({\n title: 'Delete?',\n confirmLabel: 'Delete'\n });\n if (!ok) return;\n}"
|
|
assert text_contains(body, code)
|
|
assert text_contains(code, body)
|
|
assert not text_contains("x = 1", "x = 1") # below the substance floor
|
|
|
|
|
|
def _canon(sid, kind="sym", symbol="", locations=(), signature="", code="", project_id=0):
|
|
from scribe.services.shape_ledger import Canon, _norm_text
|
|
return Canon(sid, kind, symbol, tuple(locations), signature, _norm_text(code), project_id)
|
|
|
|
|
|
def test_match_canon_prefers_the_shapes_own_project_on_a_tie():
|
|
"""The same helper recorded in two projects: the shape's own project's
|
|
record is its canon; family canon elsewhere is the fallback."""
|
|
from scribe.services.shape_ledger import match_canon
|
|
family = _canon(3, "sym", "slugify", [("lib/text.py", "slugify")], project_id=1)
|
|
own = _canon(4, "sym", "slugify", [("src/util/text.py", "slugify")], project_id=2)
|
|
assert match_canon("sym", "src/other.py", "slugify", "def slugify(t):", "",
|
|
[family, own], project_id=2) == (4, "symbol", 1.0)
|
|
assert match_canon("sym", "src/other.py", "slugify", "def slugify(t):", "",
|
|
[family, own], project_id=1) == (3, "symbol", 1.0)
|
|
# No project given → first-best stands; nothing breaks.
|
|
assert match_canon("sym", "src/other.py", "slugify", "def slugify(t):", "",
|
|
[family, own])[1] == "symbol"
|
|
|
|
|
|
def test_match_canon_orders_bases_strongest_first_and_respects_kind():
|
|
from scribe.services.shape_ledger import match_canon
|
|
|
|
confirmed = _canon(
|
|
7, "sym", "confirmed", [("frontend/src/composables/useConfirm.ts", "confirmed")],
|
|
"export async function confirmed(opts: ConfirmOptions): Promise<boolean> {",
|
|
"export async function confirmed(opts: ConfirmOptions): Promise<boolean> { /* singleton */ }",
|
|
)
|
|
mover = _canon(
|
|
8, "sym", "move_beat", [("src/forge/plot.py", "move_beat")],
|
|
"def move_beat(project_id: int, beat_id: int, after_id: int | None) -> None:",
|
|
)
|
|
btn = _canon(9, "css", ".btn-primary", [("web/buttons.css", ".btn-primary")],
|
|
".btn-primary {", ".btn-primary { color: var(--action-primary); padding: 4px 8px; border-radius: 4px; }")
|
|
canons = [confirmed, mover, btn]
|
|
|
|
# symbol: a second `confirmed` defined elsewhere answers to #7 — but the
|
|
# canon's own location never does (that row is canonical, not a proposal).
|
|
assert match_canon("sym", "src/other.ts", "confirmed", "function confirmed() {", "", canons) == (7, "symbol", 1.0)
|
|
assert match_canon("sym", "frontend/src/composables/useConfirm.ts", "confirmed",
|
|
"export async function confirmed(", "", canons) is None
|
|
# reference: a call site of the canon.
|
|
body = "async function onTrash() {\n const ok = await confirmed({ title: 'x' });\n if (!ok) return;\n}"
|
|
assert match_canon("sym", "c.vue", "onTrash", "async function onTrash() {", body, canons) == (7, "reference", 0.9)
|
|
# signature: the family shape, names blanked.
|
|
hit = match_canon("sym", "src/forge/timeline.py", "move_event",
|
|
"def move_event(project_id: int, event_id: int, after_id: int | None) -> None:",
|
|
" pass", canons)
|
|
assert hit and hit[0] == 8 and hit[1] == "signature" and hit[2] >= 0.8
|
|
# text: the canon's code contains the shape's body (a css copy), kind-matched —
|
|
# the same text as a `sym` shape matches no css canon.
|
|
css_body = ".btn-primary { color: var(--action-primary); padding: 4px 8px; border-radius: 4px; }"
|
|
assert match_canon("css", "web/other.css", "btn-big", ".btn-big {", css_body, canons) == (9, "text", 0.95)
|
|
assert match_canon("sym", "web/other.css", "btn-big", ".btn-big {", css_body, canons) is None
|
|
# nothing in play
|
|
assert match_canon("sym", "x.py", "unrelated", "def unrelated(a, b, c, d, e):", "return 1", canons) is None
|
|
|
|
|
|
def test_match_canon_gates_sym_bases_by_language_family():
|
|
"""A Python canon says nothing about a Vue body (and vice versa): the
|
|
2026-08 audit's worst proposals were `register` (MCP tool module, python)
|
|
offered for every auth view's handleSubmit that calls authStore.register()
|
|
and for a TS store's own `register`. Unknown language on either side →
|
|
no gate (the canons recorded without a language keep proposing)."""
|
|
from scribe.services.shape_ledger import Canon, _norm_text, match_canon, same_family
|
|
py_register = Canon(46, "sym", "register", (("src/scribe/mcp/tools/notes.py", "register"),),
|
|
"def register(mcp) -> None:", _norm_text("def register(mcp) -> None: ..."), 2, "python")
|
|
ts_helper = Canon(53, "sym", "apiErrorMessage", (("frontend/src/api/client.ts", "apiErrorMessage"),),
|
|
"export function apiErrorMessage(e: unknown, fallback: string): string {",
|
|
_norm_text("export function apiErrorMessage(e, fallback) { return fallback }"), 2, "typescript")
|
|
canons = [py_register, ts_helper]
|
|
vue_body = "async function handleSubmit() {\n await authStore.register(username.value);\n error.value = apiErrorMessage(e, 'x');\n}"
|
|
# The Vue handler references the TS helper, never the Python canon.
|
|
assert match_canon("sym", "frontend/src/views/RegisterView.vue", "handleSubmit",
|
|
"async function handleSubmit() {", vue_body, canons) == (53, "reference", 0.9)
|
|
# A TS store's own `register` is not a second definition of the Python one.
|
|
assert match_canon("sym", "frontend/src/stores/auth.ts", "register",
|
|
"async function register(u: string) {", "return apiPost('/api/auth/register', {u})",
|
|
[py_register]) is None
|
|
# Same family still proposes by symbol; unknown language still proposes.
|
|
assert match_canon("sym", "src/scribe/mcp/tools/other.py", "register",
|
|
"def register(mcp) -> None:", "pass", [py_register]) == (46, "symbol", 1.0)
|
|
unknown = py_register._replace(language="")
|
|
assert match_canon("sym", "frontend/src/stores/auth.ts", "register",
|
|
"async function register(u: string) {", "", [unknown]) == (46, "symbol", 1.0)
|
|
assert same_family("a.py", "python") and same_family("a.vue", "typescript")
|
|
assert same_family("a.py", "") and same_family("", "python")
|
|
assert not same_family("a.py", "vue")
|
|
|
|
|
|
def test_match_canon_reference_skips_generic_verbs():
|
|
"""A bare mention of `load`/`save`/`register` is not a call site of THIS
|
|
canon; the symbol basis still catches a second definition of the name."""
|
|
from scribe.services.shape_ledger import Canon, _norm_text, match_canon
|
|
loader = Canon(70, "sym", "load", (("frontend/src/components/A.vue", "load"),),
|
|
"async function load() {", _norm_text("async function load() { await fetch() }"), 2, "vue")
|
|
body = "async function refresh() {\n await load();\n}"
|
|
assert match_canon("sym", "frontend/src/components/B.vue", "refresh", "async function refresh() {", body, [loader]) is None
|
|
assert match_canon("sym", "frontend/src/components/B.vue", "load", "async function load() {", "", [loader]) == (70, "symbol", 1.0)
|
|
|
|
|
|
def test_match_canon_symbol_beats_everything_including_css_copies():
|
|
"""The previous test's css `btn-primary`-elsewhere case, stated plainly:
|
|
a second definition of the canon's own name is the symbol basis."""
|
|
from scribe.services.shape_ledger import match_canon
|
|
btn = _canon(9, "css", ".btn-primary", [("web/buttons.css", ".btn-primary")], ".btn-primary {", ".btn-primary { color: red; padding: 4px 8px; border-radius: 4px; }")
|
|
assert match_canon("css", "web/other.css", "btn-primary", ".btn-primary {", ".btn-primary { color: blue }", [btn]) == (9, "symbol", 1.0)
|
|
|
|
|
|
def test_derive_groups_copy_before_name_with_floors():
|
|
from scribe.services.shape_ledger import derive_groups
|
|
|
|
rows = [
|
|
("a.py", "sym", "helper", "sha1"), ("b.py", "sym", "helper", "sha1"), # identical copies
|
|
("c.py", "sym", "helper", "sha9"), # same name, 3rd file
|
|
("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"),
|
|
("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group
|
|
("i.py", "sym", "one", "s6"),
|
|
# CSS (note 2917): identical bodies under different names are NOT a
|
|
# copy — two meanings sharing the style system's look; the same
|
|
# class in two files IS a family (the name floor is 2 for css).
|
|
("j.css", "css", "muted", "same"), ("k.css", "css", "pin-auto", "same"),
|
|
("l.css", "css", "card", "c1"), ("m.css", "css", "card", "c2"),
|
|
("n.css", "css", "alone", "c3"),
|
|
]
|
|
g = derive_groups(rows)
|
|
assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")]
|
|
assert g[("c.py", "sym", "helper")] == "name:sym:helper"
|
|
assert g[("d.css", "css", "btn")] == "name:css:btn"
|
|
assert ("g.py", "sym", "main") not in g
|
|
assert ("i.py", "sym", "one") not in g
|
|
assert ("j.css", "css", "muted") not in g and ("k.css", "css", "pin-auto") not in g
|
|
assert g[("l.css", "css", "card")] == "name:css:card" == g[("m.css", "css", "card")]
|
|
assert ("n.css", "css", "alone") not in g
|
|
assert not any(v.startswith("dup:") for k, v in g.items() if k[1] == "css")
|
|
|
|
|
|
def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh():
|
|
"""#2899: the arrival-moment drift signal — derive-grouped rows created
|
|
after the previous refresh's stamp, newest first, judged rows and a
|
|
first seed (since=None) never count."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from scribe.models.code_shape import CodeShape
|
|
from scribe.services.shape_ledger import derive_new_summary
|
|
|
|
t0 = datetime(2026, 8, 22, 12, 0, tzinfo=timezone.utc)
|
|
|
|
def row(path, symbol, at, kind="css", status="scoped", group="dup:abc", basis="derive"):
|
|
r = CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind,
|
|
status=status, proposal_basis=basis, proposal_group=group)
|
|
r.created_at = at
|
|
return r
|
|
rows = [
|
|
row("v/Old.vue", "error-msg", t0 - timedelta(days=3)), # before the stamp
|
|
row("v/InceptionCard.vue", "error-msg", t0 + timedelta(hours=1)), # new copy
|
|
row("v/Other.vue", "error-msg", t0 + timedelta(hours=2)), # newer copy
|
|
row("v/J.vue", "error-msg", t0 + timedelta(hours=3), status="exempt"), # judged: never
|
|
row("s/a.py", "load", t0 + timedelta(hours=1), kind="sym", group=None, basis=None), # no family
|
|
]
|
|
out = derive_new_summary(rows, since=t0)
|
|
assert out["count"] == 2
|
|
assert [e["path"] for e in out["examples"]] == ["v/Other.vue", "v/InceptionCard.vue"]
|
|
assert out["examples"][0] == {"label": ".error-msg", "path": "v/Other.vue", "group": "dup:abc"}
|
|
assert derive_new_summary(rows, since=None) == {"count": 0, "examples": []}
|
|
assert derive_new_summary(rows, since=t0, top=1)["examples"] == [
|
|
{"label": ".error-msg", "path": "v/Other.vue", "group": "dup:abc"}]
|
|
|
|
|
|
def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows():
|
|
"""#2872: dup groups (the real copies) outrank name groups (usually
|
|
convention), wider spread first; #2869: scoped rows are in the readout."""
|
|
from scribe.models.code_shape import CodeShape
|
|
from scribe.services.shape_ledger import proposal_summary
|
|
|
|
def row(path, symbol, group, kind="css", status="scoped"):
|
|
return CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind,
|
|
status=status, proposal_basis="derive", proposal_group=group)
|
|
rows = [
|
|
# a name group of 6 across 6 files
|
|
*[row(f"v/{i}.vue", "status-badge", "name:css:status-badge") for i in range(6)],
|
|
# a dup group of 3 across 3 files (different selector names, one body)
|
|
row("v/Login.vue", "closed-msg", "dup:abc"), row("v/Reset.vue", "error-block", "dup:abc"),
|
|
row("v/Forgot.vue", "success-msg", "dup:abc"),
|
|
# a dup group of 2 in ONE file — a copy, but not across files
|
|
row("v/A.vue", "x", "dup:def"), row("v/A.vue", "y", "dup:def"),
|
|
# judged rows never count
|
|
row("v/J.vue", "closed-msg", "dup:abc", status="exempt"),
|
|
]
|
|
out = proposal_summary(rows)
|
|
# Milestone 302: with consumer paths in hand, each css group says what
|
|
# renders it — distinct files across the members; absent otherwise.
|
|
assert "consumers" not in out["derive_groups"][0]
|
|
for i, r in enumerate(rows): # unsaved rows have no id; give them one
|
|
r.id = i + 1
|
|
cpaths = {rows[0].id: ["v/0.vue", "v/Z.vue"], rows[1].id: ["v/1.vue"], rows[2].id: ["v/0.vue"]}
|
|
with_c = proposal_summary(rows, consumer_paths=cpaths)
|
|
badge = next(g for g in with_c["derive_groups"] if g["group"] == "name:css:status-badge")
|
|
assert badge["consumers"] == {"count": 3, "paths": ["v/0.vue", "v/1.vue", "v/Z.vue"]}
|
|
dup = next(g for g in with_c["derive_groups"] if g["group"] == "dup:abc")
|
|
assert dup["consumers"] == {"count": 0, "paths": []}
|
|
assert [g["group"] for g in out["derive_groups"]] == ["dup:abc", "dup:def", "name:css:status-badge"]
|
|
assert out["derive_groups"][0]["files"] == 3 and out["derive_groups"][0]["size"] == 3
|
|
assert out["derive_groups"][0]["label"] == "closed-msg (identical body)"
|
|
|
|
|
|
def test_confirm_requires_a_named_scope():
|
|
import asyncio
|
|
|
|
from scribe.services.shape_ledger import confirm_proposals
|
|
|
|
with pytest.raises(ValueError) as err:
|
|
asyncio.run(confirm_proposals(1, 2))
|
|
assert "name what you reviewed" in str(err.value)
|
|
|
|
|
|
def test_proposer_tools_are_mounted():
|
|
from scribe.mcp.server import build_mcp_server
|
|
|
|
mcp = build_mcp_server()
|
|
assert mcp._tool_manager.get_tool("confirm_shape_proposals") is not None
|
|
tool = mcp._tool_manager.get_tool("list_shapes")
|
|
assert "proposal" in tool.parameters.get("properties", {})
|
|
# #2868: the audit surfaces — compact pages and the sweep form.
|
|
assert "compact" in tool.parameters.get("properties", {})
|
|
rule = mcp._tool_manager.get_tool("classify_shapes_by_rule")
|
|
assert rule is not None
|
|
for name in ("path", "status", "pattern", "kind", "snippet_id", "reason", "include_judged"):
|
|
assert name in rule.parameters.get("properties", {}), name
|
|
|
|
|
|
# --- #2868: the bulk surfaces (pure) -----------------------------------------
|
|
|
|
|
|
def test_compact_row_carries_identity_standing_and_the_proposers_word_only():
|
|
"""A 500-row compact page must fit the tool budget: no commits, shas or
|
|
timestamps; optional fields only when set."""
|
|
from scribe.models.code_shape import CodeShape
|
|
row = CodeShape(project_id=2, repo_key="r", path="src/a.py", symbol="f", kind="sym",
|
|
status="unclassified", signature="def f(x):", body_sha="abc",
|
|
first_seen_commit="c1", last_seen_commit="c2")
|
|
assert row.to_compact() == {
|
|
"path": "src/a.py", "symbol": "f", "kind": "sym",
|
|
"status": "unclassified", "signature": "def f(x):",
|
|
}
|
|
row.status, row.snippet_id, row.classified_by = "instance", 9, "audit"
|
|
row.proposed_snippet_id, row.proposal_basis, row.proposal_score = 9, "symbol", 1.0
|
|
compact = row.to_compact()
|
|
assert compact["snippet_id"] == 9 and compact["by"] == "audit"
|
|
assert compact["proposal"]["basis"] == "symbol"
|
|
for noisy in ("first_seen_commit", "last_seen_commit", "body_sha", "created_at", "classified_at"):
|
|
assert noisy not in compact
|
|
|
|
|
|
def test_resolve_consumers_prefers_the_own_file_and_fans_out_for_shared_names():
|
|
"""Milestone 302: a class named in a template resolves to that file's
|
|
OWN row when it defines the class (a scoped rule, consumed by its own
|
|
markup); otherwise to every other definition of the name — one shared
|
|
sheet, or all of several (the map fans out rather than guessing)."""
|
|
from scribe.services.shape_ledger import resolve_consumers
|
|
css_rows = [
|
|
(1, "v/A.vue", "error-msg"), # scoped, defined + used in A
|
|
(2, "v/B.vue", "error-msg"), # scoped, defined in B, used in B and C
|
|
(3, "assets/components.css", "btn-primary"), # the shared sheet
|
|
(4, "assets/a.css", "pill"), (5, "assets/b.css", "pill"), # two shared defs
|
|
(6, "assets/c.css", "unused"),
|
|
]
|
|
refs = {
|
|
"v/A.vue": {"error-msg": 2, "btn-primary": 1, "nothing-defined": 1},
|
|
"v/B.vue": {"error-msg": 1},
|
|
"v/C.vue": {"error-msg": 1, "pill": 3},
|
|
}
|
|
assert resolve_consumers(css_rows, refs) == {
|
|
(1, "v/A.vue"): 2, # own row, not B's
|
|
(3, "v/A.vue"): 1, # the shared sheet
|
|
(2, "v/B.vue"): 1, # own row
|
|
(1, "v/C.vue"): 1, (2, "v/C.vue"): 1, # C defines none → every other definition
|
|
(4, "v/C.vue"): 3, (5, "v/C.vue"): 3, # ambiguous: both, not a guess
|
|
}
|
|
# Unknown tokens and an unreferenced row leave no trace.
|
|
assert all(sid != 6 for sid, _ in resolve_consumers(css_rows, refs))
|
|
|
|
|
|
def test_resolve_consumers_credits_every_row_a_prefix_could_have_built():
|
|
"""#2970: `status-${s}` names a class the map cannot pin down, so the
|
|
prefix reference `status-*` credits every row whose symbol starts with
|
|
the head — each under the same own-file-else-fan-out rule. Crediting all
|
|
of them is the honest reading: the alternative is calling live rules
|
|
unused, which is what the flag existed to avoid."""
|
|
from scribe.services.shape_ledger import resolve_consumers
|
|
css_rows = [
|
|
(1, "assets/app.css", "status-done"),
|
|
(2, "assets/app.css", "status-todo"),
|
|
(3, "v/Board.vue", "status-done"), # a scoped copy of one of them
|
|
(4, "assets/app.css", "btn-primary"),
|
|
(5, "assets/anim.css", "toast-enter-active"),
|
|
]
|
|
# A file that defines none of them fans out across every match.
|
|
assert resolve_consumers(css_rows, {"v/List.vue": {"status-*": 2}}) == {
|
|
(1, "v/List.vue"): 2, (2, "v/List.vue"): 2, (3, "v/List.vue"): 2,
|
|
}
|
|
# A file that DOES define one keeps the own-file rule, per symbol: its own
|
|
# status-done row, and the shared status-todo it does not define.
|
|
assert resolve_consumers(css_rows, {"v/Board.vue": {"status-*": 1}}) == {
|
|
(3, "v/Board.vue"): 1, (2, "v/Board.vue"): 1,
|
|
}
|
|
# A prefix that matches nothing is silent, and exact tokens are untouched.
|
|
assert resolve_consumers(css_rows, {"v/X.vue": {"zz-*": 1}}) == {}
|
|
assert resolve_consumers(css_rows, {"v/X.vue": {"btn-primary": 3}}) == {
|
|
(4, "v/X.vue"): 3,
|
|
}
|
|
# A transition class resolves exactly, like any other name.
|
|
assert resolve_consumers(css_rows, {"c/Toast.vue": {"toast-enter-active": 1}}) == {
|
|
(5, "c/Toast.vue"): 1,
|
|
}
|
|
|
|
|
|
def test_consumer_edges_table_cascades_with_the_shape():
|
|
from scribe.models import Base
|
|
from scribe.models.code_shape import CONSUMER_BASES, CodeShapeConsumer
|
|
assert "code_shape_consumers" in Base.metadata.tables
|
|
cols = CodeShapeConsumer.__table__.c
|
|
assert next(iter(cols.shape_id.foreign_keys)).ondelete == "CASCADE"
|
|
assert CONSUMER_BASES == ("template",)
|
|
|
|
|
|
def test_uses_edges_table_and_validation():
|
|
"""#2870: consumption is its own relation — a table that cascades with
|
|
both ends, and `uses` on a classification must be a list of ids."""
|
|
from scribe.models import Base
|
|
from scribe.models.code_shape import USE_BASES, CodeShapeUse
|
|
from scribe.services.shape_ledger import validate_classifications
|
|
assert "code_shape_uses" in Base.metadata.tables
|
|
cols = CodeShapeUse.__table__.c
|
|
assert next(iter(cols.shape_id.foreign_keys)).ondelete == "CASCADE"
|
|
assert next(iter(cols.snippet_id.foreign_keys)).ondelete == "CASCADE"
|
|
assert set(USE_BASES) == {"reference", "hook", "agent", "audit", "import"}
|
|
ok = [{"path": "a.py", "symbol": "f", "status": "instance", "snippet_id": 9, "uses": [3, 4]}]
|
|
assert validate_classifications(ok) is None
|
|
bad = [{"path": "a.py", "symbol": "f", "status": "instance", "snippet_id": 9, "uses": "3"}]
|
|
assert "uses must be a list" in validate_classifications(bad)
|
|
|
|
|
|
def test_reference_canons_names_every_used_canon_not_just_the_best():
|
|
from scribe.services.shape_ledger import Canon, _norm_text, reference_canons
|
|
a = Canon(1, "sym", "hash_token", (("src/x.py", "hash_token"),), "def hash_token(raw):", _norm_text("x"), 2, "python")
|
|
b = Canon(2, "sym", "rules_payload", (("src/y.py", "rules_payload"),), "def rules_payload(r):", _norm_text("y"), 2, "python")
|
|
ts = Canon(3, "sym", "fmtDate", (("f/d.ts", "fmtDate"),), "export function fmtDate(iso: string): string {", _norm_text("z"), 2, "typescript")
|
|
body = "def create_invitation(email):\n h = hash_token(raw)\n return rules_payload(h)\n"
|
|
assert reference_canons("sym", "src/scribe/services/auth.py", "create_invitation", body, [a, b, ts]) == [1, 2]
|
|
# the shape's own name and the other language family are never "uses"
|
|
assert reference_canons("sym", "src/x.py", "hash_token", body, [a]) == []
|
|
assert reference_canons("sym", "f/v.vue", "show", "fmtDate(x); hash_token(y)", [a, ts]) == [3]
|
|
|
|
|
|
def test_reason_codes_are_a_fixed_catalogue_and_validated():
|
|
"""#2874: an optional index beside the prose reason; unknown codes are a
|
|
structural error (the batch applies nothing)."""
|
|
from scribe.models.code_shape import REASON_CODES
|
|
from scribe.services.shape_ledger import validate_classifications
|
|
assert set(REASON_CODES) == {
|
|
"scoped-css", "one-off-handler", "test-helper", "convention-plumbing",
|
|
"pure-helper", "generated", "script", "typed-record",
|
|
}
|
|
assert "reason_code" in CodeShape.__table__.c
|
|
ok = [{"path": "a.py", "symbol": "f", "status": "exempt", "reason": "x", "reason_code": "pure-helper"}]
|
|
assert validate_classifications(ok) is None
|
|
bad = [{"path": "a.py", "symbol": "f", "status": "exempt", "reason": "x", "reason_code": "nope"}]
|
|
assert "unknown reason_code" in validate_classifications(bad)
|
|
|
|
|
|
def test_rule_matches_is_directory_glob_and_kind_aware():
|
|
from scribe.models.code_shape import CodeShape
|
|
from scribe.services.shape_ledger import rule_matches
|
|
|
|
def row(path, symbol, kind="sym"):
|
|
return CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind, status="unclassified")
|
|
|
|
r = row("frontend/src/views/LoginView.vue", "auth-card", "css")
|
|
assert rule_matches(r, path="frontend/src/views", pattern="", kind="")
|
|
assert rule_matches(r, path="frontend/src/views", pattern="auth-*", kind="css")
|
|
assert not rule_matches(r, path="frontend/src/views", pattern="auth-*", kind="sym")
|
|
assert not rule_matches(r, path="frontend/src/view", pattern="", kind="") # directory, not prefix
|
|
assert rule_matches(r, path="frontend/src/views/LoginView.vue", pattern="", kind="")
|
|
# CSS symbols compare without the leading dot, like everywhere else.
|
|
assert rule_matches(row("w/a.css", ".btn-primary", "css"), path="w", pattern="btn-*", kind="css")
|
|
assert rule_matches(row("src/scribe/services/backup.py", "_note_rows"), path="src/scribe/services", pattern="_*_rows", kind="")
|
|
assert not rule_matches(row("src/scribe/services/backup.py", "export_full_backup"), path="src/scribe/services", pattern="_*_rows", kind="")
|
|
|
|
|
|
# --- step 7: the divergence readout (pure) ----------------------------------
|
|
|
|
|
|
def _row(path, kind="sym", status="unclassified", snippet_id=None):
|
|
r = CodeShape(project_id=1, repo_key="r", path=path, symbol=path.rsplit("/", 1)[-1], kind=kind)
|
|
r.status, r.snippet_id = status, snippet_id
|
|
return r
|
|
|
|
|
|
def test_dominant_canon_needs_enough_judged_siblings_and_a_clear_majority():
|
|
from scribe.services.shape_ledger import dominant_canon
|
|
|
|
dense = [_row(f"c/{i}", status="instance", snippet_id=7) for i in range(4)] + [
|
|
_row("c/x", status="instance", snippet_id=8), _row("c/y")]
|
|
assert dominant_canon(dense) == (7, 4, 5)
|
|
sparse = [_row("c/a", status="instance", snippet_id=7), _row("c/b", status="instance", snippet_id=7)]
|
|
assert dominant_canon(sparse) is None # 2 judged < floor
|
|
split = [_row(f"c/{i}", status="instance", snippet_id=7) for i in range(2)] + [
|
|
_row(f"c/{i+5}", status="instance", snippet_id=8) for i in range(2)]
|
|
assert dominant_canon(split) is None # 50% < 60% share
|
|
# Variants are departures, not votes; canonical counts like an instance.
|
|
mixed = [_row("c/a", status="canonical", snippet_id=7)] + [
|
|
_row(f"c/{i}", status="instance", snippet_id=7) for i in range(2)] + [
|
|
_row("c/v", status="variant", snippet_id=9)]
|
|
assert dominant_canon(mixed) == (7, 3, 3)
|
|
|
|
|
|
def test_history_and_readout_tools_are_mounted_and_shape_history_is_read_only():
|
|
from scribe.mcp.server import _READ_ONLY_TOOLS, build_mcp_server
|
|
|
|
mcp = build_mcp_server()
|
|
assert mcp._tool_manager.get_tool("shape_history") is not None
|
|
assert "shape_history" in _READ_ONLY_TOOLS
|
|
assert "flag" in mcp._tool_manager.get_tool("list_shapes").parameters.get("properties", {})
|
|
|
|
|
|
def test_history_and_divergence_columns_are_pinned():
|
|
from scribe.models.code_shape import SHAPE_EVENTS, CodeShapeEvent
|
|
|
|
cols = CodeShape.__table__.c
|
|
for name in ("classified_sha", "recheck_at", "diverges_from"):
|
|
assert name in cols, name
|
|
assert "ix_code_shapes_diverges" in {ix.name for ix in CodeShape.__table__.indexes}
|
|
ev = CodeShapeEvent.__table__
|
|
fk = next(iter(ev.c.shape_id.foreign_keys))
|
|
assert fk.ondelete == "CASCADE" and fk.column.table.name == "code_shapes"
|
|
assert not ev.c.snippet_id.foreign_keys # history outlives the snippet
|
|
assert SHAPE_EVENTS == ("classified", "vanished", "reappeared", "drifted")
|
|
assert "code_shape_events" in Base.metadata.tables
|