diff --git a/alembic/versions/0082_repo_binding_ref.py b/alembic/versions/0082_repo_binding_ref.py new file mode 100644 index 0000000..30f4916 --- /dev/null +++ b/alembic/versions/0082_repo_binding_ref.py @@ -0,0 +1,26 @@ +"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294) + +Revision ID: 0082 +Revises: 0081 +Create Date: 2026-08-21 + +A repo binding used to imply the repo's default branch; the shape ledger +therefore only saw work after a merge to main, while the operator's work +lands on dev (rule 1). `ref` names the branch the coverage refresh reads — +NULL keeps today's behaviour (the forge's default branch). +""" +import sqlalchemy as sa +from alembic import op + +revision = "0082" +down_revision = "0081" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("repo_bindings", "ref") diff --git a/alembic/versions/0083_code_shape_reason_code.py b/alembic/versions/0083_code_shape_reason_code.py new file mode 100644 index 0000000..6798ae7 --- /dev/null +++ b/alembic/versions/0083_code_shape_reason_code.py @@ -0,0 +1,26 @@ +"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294) + +Revision ID: 0083 +Revises: 0082 +Create Date: 2026-08-21 + +The 2026-08 audit wrote the same free-text reason thousands of times +("scoped rule — styles one element of this view"); a judgment's WHY stays +prose, but an optional code from a fixed catalogue makes the ledger +filterable and aggregable ("how many pure helpers, how many test helpers"). +""" +import sqlalchemy as sa +from alembic import op + +revision = "0083" +down_revision = "0082" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("code_shapes", "reason_code") diff --git a/alembic/versions/0084_code_shape_uses.py b/alembic/versions/0084_code_shape_uses.py new file mode 100644 index 0000000..1da91e6 --- /dev/null +++ b/alembic/versions/0084_code_shape_uses.py @@ -0,0 +1,40 @@ +"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294) + +Revision ID: 0084 +Revises: 0083 +Create Date: 2026-08-21 + +A ledger row carries ONE snippet_id: what shape this is (instance/variant of +a canon). But a shape can also CALL several canonical helpers — a service +function that is an instance of the service-function convention and a +consumer of hash_token. The 2026-08 audit had to pick one; hook evidence +("pulled #N then wrote code referencing it") was stamped as instance when it +is a uses fact. This table holds the many-valued relation: shape → snippet, +with the basis and the evidence. Cascades with the shape and the snippet. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0084" +down_revision = "0083" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "code_shape_uses", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False), + sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False), + sa.Column("basis", sa.Text(), nullable=False), + sa.Column("evidence", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"), + ) + op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"]) + + +def downgrade() -> None: + op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses") + op.drop_table("code_shape_uses") diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index dcd222e..5a0261c 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -754,7 +754,7 @@ async function confirmDelete() {
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index b2ea759..1c468de 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.36", + "version": "0.1.37", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/skills/shape-accounting/SKILL.md b/plugin/skills/shape-accounting/SKILL.md index 2fb419e..6105faf 100644 --- a/plugin/skills/shape-accounting/SKILL.md +++ b/plugin/skills/shape-accounting/SKILL.md @@ -17,6 +17,11 @@ row carries a status: — the why IS the record. - `exempt` — judged genuinely one-off. **Reason required.** A recorded judgment, not silence — it stops the next pass re-litigating it. +- `scoped` — one-off **by construction**, stamped by the coverage sync + (a Vue component's scoped `\n" + "\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" + diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index a1e19e7..05b062e 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -18,7 +18,7 @@ def test_backup_version_is_v8(): point of the test — a payload section added without moving the version produces backups that are structurally different and indistinguishable by inspection.""" - assert backup.BACKUP_VERSION == 8 + assert backup.BACKUP_VERSION == 9 def test_not_included_lists_the_known_gaps(): @@ -115,7 +115,8 @@ async def test_export_full_backup_contains_every_declared_section(): "topic_suppressions", "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", - "note_supersessions", "code_shapes", "code_shape_events"): + "note_supersessions", "code_shapes", "code_shape_events", + "code_shape_uses"): assert key in out, f"missing export section: {key}" assert out[key] == [] diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index 6e062d9..cb07e65 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -30,7 +30,7 @@ def test_the_todo_state_is_the_default(): assert CodeShape.__table__.c.status.default.arg == "unclassified" assert "unclassified" in SHAPE_STATUSES assert set(SHAPE_STATUSES) == { - "canonical", "instance", "variant", "exempt", "unclassified", + "canonical", "instance", "variant", "exempt", "scoped", "unclassified", } assert set(SHAPE_CLASSIFIERS) == { "agent", "audit", "hook", "mechanical", "import", @@ -248,6 +248,49 @@ def test_match_canon_orders_bases_strongest_first_and_respects_kind(): 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.""" @@ -274,6 +317,32 @@ def test_derive_groups_copy_before_name_with_floors(): assert ("i.py", "sym", "one") not in g +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) + 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 @@ -291,6 +360,99 @@ def test_proposer_tools_are_mounted(): 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_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) ---------------------------------- diff --git a/tests/test_snippet_location_filter.py b/tests/test_snippet_location_filter.py index 882c4dc..29617a6 100644 --- a/tests/test_snippet_location_filter.py +++ b/tests/test_snippet_location_filter.py @@ -56,6 +56,7 @@ def test_no_parts_matches_everything(): def test_matches_exact_repo_path_and_symbol(): data = _data({"repo": "Scribe", "path": "src/scribe/x.py", "symbol": "helper"}) assert location_matches(data, {"repo": "Scribe"}) + assert location_matches(data, {"repo": "scribe"}) # repo names: case never distinguishes (#2874) assert location_matches(data, {"path": "src/scribe/x.py"}) assert location_matches(data, {"symbol": "helper"}) assert location_matches(data, {"repo": "Scribe", "symbol": "helper"}) @@ -101,7 +102,8 @@ def test_blank_recorded_part_does_not_match_a_requested_one(): def test_jsonpath_filters_within_one_locations_entry(): expr = location_jsonpath({"repo": "Scribe", "symbol": "helper"}) assert expr.startswith("$.locations[*] ? (") - assert '@.repo == "Scribe"' in expr + # repo: anchored, case-insensitive (#2874) — mirrors location_matches. + assert '@.repo like_regex "^Scribe$" flag "i"' in expr assert '@.symbol == "helper"' in expr assert " && " in expr @@ -121,8 +123,9 @@ def test_jsonpath_quotes_values_as_json_literals(): """A quote in a repo name must stay inside the literal, not end it.""" nasty = 'we"ird' expr = location_jsonpath({"repo": nasty}) - assert json.dumps(nasty) in expr + assert json.dumps("^" + nasty + "$") in expr # the regex is a JSON literal too assert '\\"' in expr + assert json.dumps(nasty) in location_jsonpath({"symbol": nasty}) def test_jsonpath_emits_parts_in_a_fixed_key_order():