CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
4.3 KiB
Python
115 lines
4.3 KiB
Python
"""Every typed record kind gets the same doors.
|
|
|
|
WHY THIS IS ONE GUARD AND NOT FIFTEEN
|
|
|
|
A kind can ship across several steps and still arrive with three of five
|
|
tools, no way to list it, and no duplicate report — with nothing red, because
|
|
every test asks "does what I built work" and none asks "is the kind
|
|
finished". A per-tool test cannot catch a MISSING tool.
|
|
|
|
So this asserts the property directly — a kind with its own `note_type` has a
|
|
full create/read/update/delete plus a listing, each one registered and each one
|
|
classified for auth — and derives the expectation from the kinds themselves. A
|
|
fourth kind added later inherits the bar without anyone remembering to.
|
|
|
|
A kind deletable only through `delete_note` is the same failure: "a kind whose
|
|
own tools offer create/read/update reads as one you cannot retire".
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
from grimoire.mcp.server import (
|
|
_DELIBERATELY_WRITE_SCOPED,
|
|
_READ_ONLY_TOOLS,
|
|
_WRITE_TOOLS,
|
|
)
|
|
from grimoire.services.dedup import _REPORT_KINDS
|
|
|
|
# singular -> the tools module, which is also the plural used by the listing.
|
|
KINDS = {"snippet": "snippets", "process": "processes", "lesson": "lessons"}
|
|
|
|
|
|
def _module(plural: str):
|
|
return importlib.import_module(f"grimoire.mcp.tools.{plural}")
|
|
|
|
|
|
def _expected_tools(singular: str, plural: str) -> list[str]:
|
|
return [
|
|
f"list_{plural}",
|
|
f"create_{singular}",
|
|
f"get_{singular}",
|
|
f"update_{singular}",
|
|
f"delete_{singular}",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("singular,plural", sorted(KINDS.items()))
|
|
def test_a_kind_has_all_five_doors(singular, plural):
|
|
"""THE guard. A kind that can be created and not listed, or updated and not
|
|
retired, is one whose gaps are invisible until someone needs the missing
|
|
half."""
|
|
module = _module(plural)
|
|
missing = [
|
|
name for name in _expected_tools(singular, plural)
|
|
if not callable(getattr(module, name, None))
|
|
]
|
|
assert not missing, f"{plural}.py is missing {missing}"
|
|
|
|
|
|
@pytest.mark.parametrize("singular,plural", sorted(KINDS.items()))
|
|
def test_every_door_is_actually_registered(singular, plural):
|
|
"""Defining the function is not offering it. `register(mcp)` is what puts a
|
|
tool on the surface, and a handler left out of that list is dead code that
|
|
every other test still exercises directly."""
|
|
module = _module(plural)
|
|
registered: list[str] = []
|
|
|
|
class _Recorder:
|
|
def tool(self, name):
|
|
registered.append(name)
|
|
return lambda fn: fn
|
|
|
|
module.register(_Recorder())
|
|
|
|
missing = [n for n in _expected_tools(singular, plural) if n not in registered]
|
|
assert not missing, f"{plural}.register() does not offer {missing}"
|
|
|
|
|
|
@pytest.mark.parametrize("singular,plural", sorted(KINDS.items()))
|
|
def test_every_door_is_classified_for_auth(singular, plural):
|
|
"""`test_mcp_auth` requires every registered tool to sit in exactly one of
|
|
the three sets. Asserted per KIND as well, because that test enumerates
|
|
what is registered — so a whole module wired up with none of its tools
|
|
classified is caught here by the kind that owns them."""
|
|
classified = _READ_ONLY_TOOLS | _WRITE_TOOLS | _DELIBERATELY_WRITE_SCOPED
|
|
missing = [n for n in _expected_tools(singular, plural) if n not in classified]
|
|
assert not missing, f"unclassified in server.py: {missing}"
|
|
|
|
|
|
@pytest.mark.parametrize("singular", sorted(KINDS))
|
|
def test_every_kind_can_be_asked_whether_it_holds_duplicates(singular):
|
|
"""A kind with no report is one whose duplicates are found by accident.
|
|
|
|
It matters most where the write-path gate is deliberately permissive: a
|
|
lesson's bar sits above the band where two genuinely different lessons
|
|
would block each other, and the report is where that tolerance is meant to
|
|
be reviewed.
|
|
"""
|
|
assert singular in _REPORT_KINDS
|
|
|
|
|
|
def test_the_surface_guards_can_fail():
|
|
"""Falsify the shape these assert against, so a guard that has
|
|
quietly stopped describing anything cannot pass by describing nothing."""
|
|
assert _expected_tools("lesson", "lessons") == [
|
|
"list_lessons", "create_lesson", "get_lesson",
|
|
"update_lesson", "delete_lesson",
|
|
]
|
|
# A kind that does not exist has no module — the lookup is real, not a
|
|
# getattr that returns None for everything.
|
|
with pytest.raises(ModuleNotFoundError):
|
|
_module("widgets")
|