CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.
Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.
The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""A DERIVED `data` mirror survives the GENERIC note door.
|
|
|
|
Some kinds store a queryable mirror in `notes.data` that is derived from the
|
|
body: a snippet (name, when_to_use, locations…) and a lesson (what, the
|
|
trigger, what taught it). Their own updaters compose it from the field set they
|
|
just merged, so those were never the problem — the problem is every other way
|
|
the body can be written. `update_note` is a `hasattr` loop, and both doors
|
|
reach it: PATCH /api/notes/<id> and the MCP update_note tool. The Knowledge
|
|
feed hands you that path, because a card there routes to /notes/:id.
|
|
|
|
The failure is silent and the wrong way round, because every read PREFERS the
|
|
mirror — deliberately, since parsing markdown to answer what an index can
|
|
answer is how a hot path rots.
|
|
|
|
- A SNIPPET goes on reporting its old repo/path/symbol to the location
|
|
reverse lookup and to prior-art recall while displaying its new body: a
|
|
record surfaced with full authority and wrong, which the drift-check
|
|
docstring calls worse than having no record at all.
|
|
|
|
- A LESSON is worse. Its mirror holds the TRIGGER, and the trigger is the
|
|
entire retrieval story — a stale one keeps the lesson firing for the
|
|
situation it used to name while it displays the one it now names.
|
|
|
|
A fix that tests one constant does not generalise: the next kind arrives with
|
|
the same design and none of the protection. The registry tests below assert
|
|
the PROPERTY — every kind with a derived mirror is registered — so a third
|
|
kind fails here rather than shipping quiet.
|
|
"""
|
|
import inspect
|
|
|
|
import pytest
|
|
from tests.helpers import drive_update_note as _update
|
|
from tests.helpers import fake_lesson, fake_note, fake_snippet
|
|
|
|
OLD_MIRROR = {
|
|
"name": "debounce",
|
|
"language": "javascript",
|
|
"locations": [{"repo": "Grimoire", "path": "old/place.js", "symbol": "debounce"}],
|
|
"verification": {"status": "ok", "code_sha": "abc", "checked_at": "2026-01-01"},
|
|
"provenance": {"commit_sha": "deadbeef"},
|
|
}
|
|
|
|
MOVED_BODY = (
|
|
"**Locations:**\n"
|
|
"- `Grimoire` · `new/place.ts` · `debounce`\n\n"
|
|
"```typescript\nexport const debounce = 1;\n```\n"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_body_write_moves_the_mirror_with_it():
|
|
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
|
|
await _update(note, body=MOVED_BODY)
|
|
assert note.data["locations"] == [
|
|
{"repo": "Grimoire", "path": "new/place.ts", "symbol": "debounce"}
|
|
], "the mirror still describes where the snippet used to live"
|
|
assert note.data["language"] == "typescript"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_verdict_and_provenance_are_carried_not_dropped():
|
|
"""Neither is in the body to parse, so recomposing must carry them. An
|
|
ordinary edit must not erase the last drift check — and it needs no
|
|
invalidation branch either: `code_sha` is recomputed from the new code, so
|
|
a verdict stamped against the old code expires itself on read."""
|
|
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
|
|
await _update(note, body=MOVED_BODY)
|
|
assert note.data["verification"] == OLD_MIRROR["verification"]
|
|
assert note.data["provenance"] == OLD_MIRROR["provenance"]
|
|
assert note.data["code_sha"] != OLD_MIRROR["verification"]["code_sha"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_explicit_data_wins_over_recomposition():
|
|
"""`update_snippet` composes the mirror from the merged field set it holds
|
|
and passes it here. That caller knows things the body cannot be re-read for
|
|
— which locations were replaced, whether provenance survives the edit — so
|
|
an explicit mirror must not be recomputed out from under it."""
|
|
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
|
|
authoritative = {"name": "from the service", "locations": []}
|
|
await _update(note, body=MOVED_BODY, data=authoritative)
|
|
assert note.data == authoritative
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_plain_note_is_left_alone():
|
|
"""Only snippets carry a mirror; a note's `data` must not be invented."""
|
|
note = fake_note(note_type="note", data=None, project_id=None)
|
|
await _update(note, body="just some prose")
|
|
assert note.data is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_write_that_cannot_change_the_parse_does_not_touch_the_mirror():
|
|
"""Status, priority, project — none of them is an input to the body parser,
|
|
so recomposing on them would be work for nothing and would rebuild a mirror
|
|
from a body nobody claimed to have changed."""
|
|
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
|
|
await _update(note, project_id=4)
|
|
assert note.data == OLD_MIRROR
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_title_change_reaches_the_mirror_too():
|
|
"""A snippet's NAME lives in its title, not its body — `parse_snippet_fields`
|
|
reads both, so both are triggers."""
|
|
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
|
|
await _update(note, title="throttle — cap a callback's rate")
|
|
assert note.data["name"] == "throttle"
|
|
assert note.data["when_to_use"] == "cap a callback's rate"
|
|
|
|
|
|
# ── the registry, rather than a chain of ifs ─────────────────────────────────
|
|
|
|
|
|
def test_every_kind_with_a_derived_mirror_is_registered():
|
|
"""The load-bearing one. A single `if` naming one kind leaves the next
|
|
kind uncovered."""
|
|
from scribe.services.lessons import LESSON_NOTE_TYPE
|
|
from scribe.services.notes import _mirror_recomposers
|
|
from scribe.services.snippets import SNIPPET_NOTE_TYPE
|
|
|
|
table = _mirror_recomposers()
|
|
assert SNIPPET_NOTE_TYPE in table, "snippets lost their mirror recomposer"
|
|
assert LESSON_NOTE_TYPE in table, (
|
|
"a lesson's `data` holds its trigger and `lesson_trigger` prefers it, "
|
|
"so a body edit through the generic door would leave the lesson being "
|
|
"retrieved for a situation it no longer names"
|
|
)
|
|
|
|
|
|
def test_the_dispatch_is_a_lookup_not_a_named_kind():
|
|
"""Asserted on structure. A lookup extends in one line in one
|
|
place; naming a kind inline is the shape that left lessons uncovered."""
|
|
from scribe.services import notes as notes_module
|
|
|
|
src = inspect.getsource(notes_module.update_note)
|
|
assert "_mirror_recomposers()" in src
|
|
assert "SNIPPET_NOTE_TYPE" not in src, (
|
|
"update_note names one kind inline — the dispatch must be a lookup"
|
|
)
|
|
|
|
|
|
def test_each_recomposer_takes_the_note_and_nothing_else():
|
|
"""A registry entry with the wrong signature fails inside a generic PATCH,
|
|
which is the one moment nobody is watching."""
|
|
from scribe.services.notes import _mirror_recomposers
|
|
|
|
for kind, fn in _mirror_recomposers().items():
|
|
assert callable(fn), f"{kind} maps to something not callable"
|
|
assert len(inspect.signature(fn).parameters) == 1, kind
|
|
|
|
|
|
# ── a lesson's mirror moves with its body ────────────────────────────────────
|
|
|
|
|
|
NEW_TRIGGER = "a CI run has sat in_progress far longer than its suite takes"
|
|
|
|
|
|
def _lesson_body(trigger, insight="Read the job log.", sources=None):
|
|
from scribe.services.lessons import compose_body
|
|
return compose_body(insight, trigger, sources)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_body_write_moves_a_lessons_trigger_with_it():
|
|
from scribe.services.lessons import TRIGGER_KEY
|
|
|
|
what = "Read the job log before waiting longer"
|
|
note = fake_lesson(
|
|
title=what,
|
|
data={TRIGGER_KEY: "a CI run is slow", "what": what},
|
|
project_id=None,
|
|
)
|
|
await _update(note, body=_lesson_body(NEW_TRIGGER))
|
|
assert note.data[TRIGGER_KEY] == NEW_TRIGGER, (
|
|
"the mirror kept the old trigger — the lesson would still be retrieved "
|
|
"for the situation it no longer names"
|
|
)
|
|
assert note.data["what"] == what
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_subject_containing_an_em_dash_still_splits():
|
|
"""Why `untrigger_title` is given the trigger instead of splitting on the
|
|
separator: a subject may legitimately contain one. The title here is an
|
|
UN-MIGRATED one, still carrying its trigger, because that
|
|
is the row the inverse still has to read correctly."""
|
|
from scribe.services.embeddings import trigger_title
|
|
from scribe.services.lessons import TRIGGER_KEY
|
|
|
|
what = "A wait with no deadline — the shape, not the symptom"
|
|
trigger = "you are about to await something crossing a process boundary"
|
|
note = fake_lesson(
|
|
title=trigger_title(what, trigger), data=None, project_id=None,
|
|
)
|
|
await _update(note, body=_lesson_body(trigger))
|
|
assert note.data["what"] == what
|
|
assert note.data[TRIGGER_KEY] == trigger
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dropping_the_provenance_line_drops_it_from_the_mirror():
|
|
"""The body is the authority. Carrying a value the reader just deleted is
|
|
the failure this recompose exists to prevent, not a courtesy — the
|
|
opposite call from a snippet's `verification`, which is carried because it
|
|
was never in the body to delete."""
|
|
from scribe.services.lessons import SOURCES_KEY
|
|
|
|
note = fake_lesson(
|
|
title="Something learned",
|
|
data={SOURCES_KEY: [999]},
|
|
project_id=None,
|
|
)
|
|
await _update(note, body=_lesson_body("a situation")) # no Learned from:
|
|
assert SOURCES_KEY not in note.data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_explicit_data_wins_for_a_lesson_too():
|
|
"""`update_lesson` composes the mirror from the merged field set and passes
|
|
it here; that caller knows things a re-read of the body cannot recover."""
|
|
from scribe.services.lessons import TRIGGER_KEY
|
|
|
|
note = fake_lesson(data={TRIGGER_KEY: "old"}, project_id=None)
|
|
authoritative = {TRIGGER_KEY: "from the service", "what": "x"}
|
|
await _update(note, body=_lesson_body(NEW_TRIGGER), data=authoritative)
|
|
assert note.data == authoritative
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_lesson_title_change_reaches_the_mirror():
|
|
"""A lesson's subject lives in its title, so a title edit is a trigger for
|
|
recomposition exactly as it is for a snippet's name."""
|
|
from scribe.services.lessons import TRIGGER_KEY
|
|
|
|
trigger = "two absolute siblings overlap"
|
|
note = fake_lesson(
|
|
body=_lesson_body(trigger),
|
|
data={TRIGGER_KEY: trigger, "what": "the old subject"},
|
|
project_id=None,
|
|
)
|
|
await _update(note, title="the new subject")
|
|
assert note.data["what"] == "the new subject"
|
|
assert note.data[TRIGGER_KEY] == trigger
|
|
|
|
|
|
# ── the join and its inverse ─────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("subject", "trigger"),
|
|
[
|
|
("a subject", "a trigger"),
|
|
("a subject — with a dash", "a trigger"),
|
|
("a subject", ""),
|
|
("", "a trigger"),
|
|
("a subject", "a trigger — with a dash"),
|
|
],
|
|
)
|
|
def test_untrigger_title_inverts_trigger_title(subject, trigger):
|
|
"""The join's inverse lives beside it rather than in whichever caller
|
|
wanted it."""
|
|
from scribe.services.embeddings import trigger_title, untrigger_title
|
|
|
|
title = trigger_title(subject, trigger)
|
|
assert untrigger_title(title, trigger) == (subject or trigger).strip()
|
|
|
|
|
|
def test_untrigger_title_degrades_to_the_whole_title():
|
|
"""A record written before the join existed still answers with something a
|
|
human recognises rather than with ""."""
|
|
from scribe.services.embeddings import untrigger_title
|
|
|
|
assert untrigger_title("a plain old title", "") == "a plain old title"
|
|
assert untrigger_title("a plain old title", "a trigger it lacks") == (
|
|
"a plain old title"
|
|
)
|
|
|
|
|
|
def test_the_trigger_separator_is_spelled_in_exactly_one_place():
|
|
"""Two literals that must match are one edit away from not matching."""
|
|
import pathlib
|
|
|
|
root = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe"
|
|
offenders = [
|
|
str(p.relative_to(root)) for p in root.rglob("*.py")
|
|
if '" \u2014 "' in p.read_text() and p.name != "embeddings.py"
|
|
]
|
|
assert not offenders, (
|
|
f"the trigger separator is spelled inline in {offenders} — use "
|
|
f"TRIGGER_SEP, trigger_title or untrigger_title"
|
|
)
|
|
|
|
|
|
def test_the_mirror_guards_can_fail():
|
|
"""Shown turning red once."""
|
|
from scribe.services.lessons import TRIGGER_KEY, recompose_data
|
|
|
|
bare = fake_lesson(title="just a title", body="no composed lines", data=None)
|
|
assert TRIGGER_KEY not in recompose_data(bare)
|