Files
FabledScribe/tests/test_record_kind_surface.py
T
bvandeusenandClaude Opus 5 6a2476addb
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
feat(records): every typed kind gets all five doors and a duplicate report (#4164)
Arising from #3731, which shipped a lesson with three of five tools and logged
the rest rather than widening its own scope. The operator's framing on reading
that: each kind deserves CRUD functions and to show up in the search and report
functions. So this fixes the property, not the two instances.

WHAT WAS MISSING FOR A LESSON: no delete, no list, and no duplicate report.
`delete_lesson` is the #2250 situation exactly — the trash is kind-agnostic so
`delete_note` always reached a lesson, but nothing said so, and a kind whose own
tools offer create/read/update reads as one you cannot retire. `list_lessons` is
the only way to ask what has been learned at all: `get_lesson` needs an id you
already have, and semantic search returns what resembles a query, never the set.

APPLYING THE RULE FOUND THE SAME REPORT GAP FOR PROCESSES, which have had full
CRUD for months and have never been in `_REPORT_KINDS` either. Both are in now,
each compared only against its own kind.

The lesson report default is 0.90 — the general semantic floor, deliberately
BELOW its own write-path bar of 0.96. The gate is permissive on purpose so it
does not refuse two genuinely different lessons whose triggers read alike, and
that tolerance is precisely what wants reviewing later, so the report looks at
the band the gate was told to let through. Safe there and not at the gate,
because a report proposes and the operator picks where the gate blocks a write.

A BUG CAUGHT BEFORE IT SHIPPED: `list_lessons` first read the trigger from
`it["data"]`, which `_note_to_item` does not carry — it projects named keys off
the mirror (`language`, `verification`) rather than the column. Every row would
have listed an empty trigger, which on a kind whose whole point is the trigger
is the failure looking like the feature. `when_to_apply` is now projected there
beside the others, so every listing surface gets it, including step 7's UI.

The guard asserts the PROPERTY rather than the instances: for each typed kind,
all five tools exist, are actually offered by register(), are classified for
auth, and the kind has a duplicate report. Derived from the kinds themselves, so
a fourth inherits the bar. A per-tool test cannot catch a missing tool, which is
why four steps of milestone 385 went green over this.

`find_duplicate_records` now validates against `_REPORT_KINDS` instead of its
own literal — the second copy is what would have refused a kind the service
already supported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-18 18:35:13 -04:00

116 lines
4.4 KiB
Python

"""Every typed record kind gets the same doors (#4164).
WHY THIS IS ONE GUARD AND NOT FIFTEEN
Milestone 385 shipped `lesson` across four steps and it still arrived with
three of five tools, no way to list it, and no duplicate report. Nothing was
red, because every test asked "does what I built work" and none asked "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.
#2250 is the same failure one kind earlier: processes could always be deleted
through `delete_note`, but nothing said so, and "a kind whose own tools offer
create/read/update reads as one you cannot retire".
"""
from __future__ import annotations
import importlib
import pytest
from scribe.mcp.server import (
_DELIBERATELY_WRITE_SCOPED,
_READ_ONLY_TOOLS,
_WRITE_TOOLS,
)
from scribe.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"scribe.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():
"""Rule 167: 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")