fix(lessons): a derived mirror survives the generic note door, by kind not by name (#3734)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 23s

Groundwork for step 7, and a data-integrity fix in its own right.

Two kinds keep a queryable mirror in `notes.data` derived from their body:
snippets and, since milestone 385, lessons. Every read prefers the mirror —
deliberately, because parsing markdown to answer what an index can answer is
how a hot path rots. So a write that moves the body must move the mirror.

#3128 found that hole for snippets and plugged it with a hard-coded
`if note.note_type == SNIPPET_NOTE_TYPE`. The plug was correct and did not
generalise: lessons arrived with the same design and none of the protection,
which is precisely the "don't add a fourth instance" defect #3734 was told to
avoid.

The cost is higher for a lesson. A stale snippet mirror reports the wrong
path. A stale lesson mirror reports the wrong TRIGGER, and the trigger is the
whole retrieval story — the lesson goes on firing for the situation it used
to name while displaying the one it now names. Silent, and confident.

So `update_note` now dispatches through `_mirror_recomposers()`, a
note_type -> recomposer table. A kind with a derived mirror is covered by
registering it, not by someone remembering to widen an if.

`lessons.recompose_data` is the lesson's entry. It recovers the subject with
`embeddings.untrigger_title` — new, and deliberately placed beside the join it
inverts rather than in the caller that wanted it, because a separator spelled
in two files is a separator that will one day be changed in one of them
(#3207). `TRIGGER_SEP` is now the one spelling, and `parse_snippet_fields`
uses it too; it had the third copy inline.

The two inverses stay distinct on purpose: a snippet partitions at the first
separator (its name is a symbol), a lesson strips an exact known suffix (its
subject may legitimately contain a dash). Different algorithms, one constant,
so they cannot disagree about where the seam is.

Provenance is DROPPED when the body drops it, which is the opposite call from
a snippet's `verification` — that is carried because it was never in the body
to delete. The body is the authority; carrying a value the reader just removed
is the failure the recompose exists to prevent.

Tests: test_snippet_mirror_generic_door.py becomes
test_derived_mirror_generic_door.py, since the concern is now plural. The
registry property is asserted directly (every kind with a mirror is in the
table; the dispatch names no kind inline), plus the lesson cases and the
join/inverse round-trip. `fake_lesson` moves to tests/helpers.py — it existed
in test_lesson_surfacing.py and a second copy was about to be written — and
gains the explicit `None`s `fake_snippet` carries, because update_note reads
`verify_with` and a MagicMock is truthy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-19 13:58:54 -04:00
co-authored by Claude Opus 5
parent 1ade956cd5
commit 1252d0e305
8 changed files with 462 additions and 137 deletions
+34 -1
View File
@@ -203,6 +203,12 @@ def embedding_text(title: str | None, body: str | None) -> str:
return f"{title}\n{body}".strip() if body else title
# The join between a situation-keyed record's subject and its trigger. A
# CONSTANT because `untrigger_title` below has to spell the same thing to undo
# it, and two literals that must match are one edit away from not matching.
TRIGGER_SEP = ""
def trigger_title(subject: str | None, trigger: str | None) -> str:
"""`{subject}{trigger}` — the title half of a situation-keyed document.
@@ -226,10 +232,37 @@ def trigger_title(subject: str | None, trigger: str | None) -> str:
subject = (subject or "").strip()
trigger = (trigger or "").strip()
if subject and trigger:
return f"{subject}{trigger}"
return f"{subject}{TRIGGER_SEP}{trigger}"
return subject or trigger
def untrigger_title(title: str | None, trigger: str | None) -> str:
"""The subject back out of a `trigger_title` — the inverse of the join.
Kept HERE, beside the join, for the reason the join itself was
consolidated: a separator spelled in two files is a separator that will one
day be changed in one of them. #3207 records the shape — derive it before
the third copy — and an inverse written in a caller is that third copy
wearing a different name.
Needs the trigger passed in rather than guessing at the separator, because
a subject may legitimately contain an em dash. Given the trigger, the
suffix is exact and the split cannot be wrong.
Degrades to the whole title when the suffix is absent — a record written
before the join existed, or one with no trigger yet, still answers with
something a human recognises rather than with "".
"""
title = (title or "").strip()
trigger = (trigger or "").strip()
if not trigger:
return title
suffix = f"{TRIGGER_SEP}{trigger}"
if title.endswith(suffix):
return title[: -len(suffix)].strip()
return title
# --- chunking (#280): the document shape ------------------------------------
#
# bge-small reads at most 512 tokens and fastembed silently truncates the rest,
+40
View File
@@ -317,6 +317,46 @@ def compose_data(
return data
def recompose_data(note) -> dict:
"""Rebuild a lesson's `data` mirror from its own title and body.
For the GENERIC note door. `update_lesson` composes the mirror itself from
the merged field set and never needs this; a plain `update_note(body=...)`
has no idea the mirror exists and would leave it behind.
THE COST OF LEAVING IT BEHIND IS HIGHER HERE THAN FOR A SNIPPET. A stale
snippet mirror reports the wrong path. A stale lesson mirror reports the
wrong TRIGGER — and `lesson_trigger` prefers the mirror, so the lesson goes
on being retrieved for the situation it used to name while displaying the
one it now names. The trigger is the entire retrieval story (step 3), so
that is not a degraded record; it is a record that fires at the wrong
moment and looks right when it does.
The body is the authority and the mirror is derived — already this file's
rule. This is its enforcement on the path that bypasses `update_lesson`.
The subject comes back out of the title through `untrigger_title`, the
inverse of the join that composed it, rather than by splitting on a
separator spelled a second time here.
"""
from scribe.services.embeddings import untrigger_title
body = getattr(note, "body", None) or ""
trigger_match = _BODY_TRIGGER_RE.search(body)
trigger = trigger_match.group(1).strip() if trigger_match else ""
what = untrigger_title(getattr(note, "title", None), trigger)
# Sources through the normal read, which already falls back body →
# arose_from_id. A body edit that drops the provenance line should drop
# the mirror's copy too: the body is the authority, and carrying a value
# the reader just deleted is the failure this function exists to prevent.
sources_match = _BODY_SOURCES_RE.search(body)
sources = (
normalize_sources(_ID_RE.findall(sources_match.group(1)))
if sources_match else []
)
return compose_data(what, trigger, sources)
async def create_lesson(
user_id: int,
*,
+48 -22
View File
@@ -1,6 +1,6 @@
import logging
import re
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from datetime import date, datetime, timezone
from sqlalchemy import func, or_, select, text
@@ -11,14 +11,44 @@ from scribe.models.base import iso
logger = logging.getLogger(__name__)
# The fields `snippets.parse_snippet_fields` reads. Writing any of them can
# change what a snippet's derived `data` mirror should say, so update_note
# recomposes the mirror when one moves. Kept here as a set of NAMES rather
# than imported, because it describes update_note's own `fields` dict, not the
# parser's signature.
# The fields a derived `data` mirror is parsed out of. Writing any of them can
# change what the mirror should say, so update_note recomposes it when one
# moves. Kept here as a set of NAMES rather than imported, because it
# describes update_note's own `fields` dict, not any parser's signature.
_PARSED_FROM_BODY = frozenset({"title", "body", "tags"})
def _mirror_recomposers() -> dict[str, Callable[[Note], dict]]:
"""note_type -> the function that rebuilds that kind's derived `data`.
A TABLE rather than a chain of `if note_type == ...`, because the previous
shape tested one constant and the next kind with a derived mirror was
silently not covered — which is exactly what happened: the snippet guard
(#3128) was hard-coded, and lessons arrived in milestone 385 with the same
body-is-authority/`data`-is-mirror design and none of the protection.
The failure is invisible from here. Nothing raises, nothing logs; the row
simply keeps answering queries from a mirror that no longer matches its
body, and every surface that prefers the mirror — which is all of them, by
design, because parsing markdown to answer what an index can answer is how
a hot path rots — reports the old value confidently.
Imported inside the function, not at module scope: both services call back
into this module (`update_snippet`/`update_lesson` -> `update_note`), so a
top-level import is a cycle.
"""
from scribe.services.lessons import (
LESSON_NOTE_TYPE, recompose_data as _lesson_mirror,
)
from scribe.services.snippets import (
SNIPPET_NOTE_TYPE, recompose_data as _snippet_mirror,
)
return {
SNIPPET_NOTE_TYPE: _snippet_mirror,
LESSON_NOTE_TYPE: _lesson_mirror,
}
# Text fields where EMPTY MEANS NULL (milestone 317). The sweep's whole signal
# is `verify_with IS NULL` = "this is a decision, there is nothing to go and
# check". An empty string that is not NULL makes a norm look like a constraint
@@ -604,23 +634,19 @@ async def update_note(
# costs exactly what the sweep exists to catch.
if note.verify_with != check_before:
note.verified_at = None
# A snippet's `data` is DERIVED from its body — so a write that moves
# the body through this generic door must move the mirror with it
# (#3128). Without this, PATCH /api/notes/<snippet_id> {body} left the
# mirror behind, and snippet_fields PREFERS the mirror: the row went on
# reporting its old repo/path/symbol to prior-art recall while showing
# its new body. `update_snippet` composes the mirror itself and passes
# it explicitly, so an explicit `data` always wins — the caller that
# knows the field set beats the one that can only re-read the body.
# Some kinds derive `data` from their body — so a write that moves the
# body through this generic door must move the mirror with it (#3128).
# Without this, PATCH /api/notes/<id> {body} left the mirror behind,
# and every read PREFERS the mirror: a snippet went on reporting its
# old repo/path/symbol to prior-art recall while showing its new body,
# and a lesson would go on being retrieved for the situation it used to
# name. The kind's own updater composes the mirror itself and passes it
# explicitly, so an explicit `data` always wins — the caller that knows
# the field set beats the one that can only re-read the body.
if "data" not in fields and not _PARSED_FROM_BODY.isdisjoint(fields):
# Imported here, not at module scope: services/snippets.py calls
# back into this module (update_snippet -> update_note), so a
# top-level import is a cycle.
from scribe.services.snippets import (
SNIPPET_NOTE_TYPE, recompose_data,
)
if note.note_type == SNIPPET_NOTE_TYPE:
note.data = recompose_data(note)
recompose = _mirror_recomposers().get(note.note_type or "")
if recompose is not None:
note.data = recompose(note)
# Auto-set lifecycle timestamps on status transitions
if "status" in fields:
_now = datetime.now(timezone.utc)
+13 -1
View File
@@ -275,9 +275,21 @@ def parse_snippet_fields(
``locations`` is a list of {repo,path,symbol}; ``repo``/``path``/``symbol``
mirror the FIRST location for back-compat with the single-location callers."""
from scribe.services.embeddings import TRIGGER_SEP
title = title or ""
body = body or ""
name, _, when_from_title = title.partition("")
# The inverse of `embeddings.trigger_title`, at the SEPARATOR it composed
# with — imported rather than spelled again, because a separator written
# in two files is a separator that will one day be changed in one of them.
#
# `partition` rather than the `untrigger_title` a lesson uses: that one is
# handed the trigger and strips an exact suffix, which a lesson needs
# because its subject may legitimately contain a dash. A snippet's name is
# a symbol, so the first separator is the right split and no trigger has
# to be known in advance. Two inverses, suited to their callers; one
# constant, so they cannot disagree about where the seam is.
name, _, when_from_title = title.partition(TRIGGER_SEP)
fields = {
"name": name.strip(),
"when_to_use": when_from_title.strip(),
+28
View File
@@ -191,6 +191,34 @@ def fake_snippet(**attrs) -> MagicMock:
}, attrs)
def fake_lesson(**attrs) -> MagicMock:
"""A stand-in lesson: a note whose `note_type` is what makes it one.
The title carries the trigger because `compose_title` builds it that way —
`{what}{when it applies}` — so a menu line rendering only the title is
already showing the reader when this lesson applies. Tests that used a bare
title here would be testing a record the product cannot create.
The check fields and `arose_from_id` are explicitly None for the reason
`fake_snippet`'s `data` is: `update_note` reads `verify_with` and
`expires_when` to decide whether to run the check-field guard, and an
auto-created MagicMock attribute is truthy — so a default lesson driven
through the update path would take a branch no real record takes.
"""
attrs.setdefault(
"title",
"Give absolutely-positioned siblings an explicit stacking order — "
"placing two absolutely-positioned elements in the same area",
)
attrs.setdefault("data", {"when_to_apply": "two absolute siblings overlap"})
attrs.setdefault("status", None)
attrs.setdefault("arose_from_id", None)
attrs.setdefault("verify_with", None)
attrs.setdefault("expires_when", None)
attrs.setdefault("verified_at", None)
return fake_note(note_type="lesson", **attrs)
def fake_project(**attrs) -> MagicMock:
"""design_system_id is explicit: a truthy auto-attribute would route every
project through the design-system branch and out to a real database."""
+298
View File
@@ -0,0 +1,298 @@
"""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 went 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 (#3128).
- 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.
#3128's fix was correct and did not generalise: it tested one constant, so
milestone 385's lesson arrived 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 (#3734).
"""
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": "Scribe", "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"
"- `Scribe` · `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": "Scribe", "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. Before #3734 this was a single `if` naming
snippets, and the kind added next was simply not covered."""
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, "the #3128 fix was lost"
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 (#3734)"
)
def test_the_dispatch_is_a_lookup_not_a_named_kind():
"""Asserted on structure (rule 167). 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 again — that is the shape #3734 replaced"
)
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, compose_title
what = "Read the job log before waiting longer"
note = fake_lesson(
title=compose_title(what, NEW_TRIGGER),
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."""
from scribe.services.lessons import TRIGGER_KEY, compose_title
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=compose_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, compose_title
note = fake_lesson(
title=compose_title("Something learned", "a situation"),
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, compose_title
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=compose_title("the new subject", trigger))
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):
"""#3207's shape: the join had three copies before it was consolidated, so
its 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 (#3207)"
)
def test_the_mirror_guards_can_fail():
"""Rule 167: 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)
+1 -18
View File
@@ -22,7 +22,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import plugin_context as pc
from scribe.services.lessons import LESSON_NOTE_TYPE
from tests.helpers import fake_note, writepath_cfg
from tests.helpers import fake_lesson, fake_note, writepath_cfg
pytestmark = pytest.mark.usefixtures("_no_supersession")
@@ -33,23 +33,6 @@ _CFG = {"enabled": True, "threshold": 0.55, "top_k": 3}
_BINDING_PHRASE = "before deciding it does not apply"
def fake_lesson(**attrs):
"""A stand-in lesson: a note whose `note_type` is what makes it one.
The title carries the trigger because `compose_title` builds it that way —
`{what}{when it applies}` — so a menu line rendering only the title is
already showing the reader when this lesson applies. Tests that used a bare
title here would be testing a record the product cannot create.
"""
attrs.setdefault(
"title",
"Give absolutely-positioned siblings an explicit stacking order — "
"placing two absolutely-positioned elements in the same area",
)
attrs.setdefault("data", {"when_to_apply": "two absolute siblings overlap"})
return fake_note(note_type=LESSON_NOTE_TYPE, **attrs)
async def _menu(main_hits, *, lesson_hits=None, reuse_hits=None, cfg=None,
exclude_ids=None, rec=None, surf=None):
"""Run the prompt menu with each query stubbed by the kinds it asks for."""
-95
View File
@@ -1,95 +0,0 @@
"""A snippet's `data` mirror survives the GENERIC note door.
`notes.data` is derived from the body. The snippet service always composed it
from the field set it had just merged, so `update_snippet` was never the
problem — the problem was every other way a snippet's body could be written.
`update_note` is a `hasattr` loop with no snippet awareness, and both doors
reach it: PATCH /api/notes/<id> and the MCP update_note tool. The Knowledge
feed handed you that path, because a snippet card there routed to /notes/:id.
The failure was silent and the wrong way round: `snippet_fields` PREFERS the
mirror, so the row went 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 (#3128).
"""
import pytest
from tests.helpers import drive_update_note as _update
from tests.helpers import fake_note, fake_snippet
OLD_MIRROR = {
"name": "debounce",
"language": "javascript",
"locations": [{"repo": "Scribe", "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"
"- `Scribe` · `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": "Scribe", "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"