Files
FabledScribe/tests/test_integration_snippet_locations.py
T
bvandeusen dd1b5e5ddb
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Failing after 27s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m42s
feat(snippets): reverse lookup — find snippets by repo/path/symbol
"What canonical helpers already live in this file?" was unanswerable:
location lived only in the body markdown. It is now a jsonpath containment
query over the `notes.data` mirror added by migration 0070.

- One predicate in two dialects in services/knowledge.py: SQL (`data @?`,
  applied in the browse arm and the keyword arm before count/pagination, so
  totals stay honest) and Python (`location_matches`, for the semantic arm
  which post-filters candidates it already holds). Both must change together.
- Parts are ANDed within a SINGLE locations entry — repo A in one entry and
  path B in another is not "recorded at A/B". `path` also matches as a
  directory prefix, via jsonpath `starts with` rather than `@>`, which the
  same GIN index serves.
- `repo`/`path`/`symbol` reach the service, the REST list and the MCP tool
  under one name with one default (rule #33); the MCP docstring teaches the
  place form, and so does the reusing-code skill (plugin.json bumped).
- UI: a Location disclosure beside the snippet search, with its own empty
  state — "nothing kept there, so what you're about to write is new."

Settles #2083's open question (pre-0070 NULL `data`) by backfilling after
all: `backfill_snippet_data` runs at startup, deriving the mirror from the
body with the same parser the read path trusts. 0070's caution was about
mangling a hand-edited body; this never touches the body. The alternative
was a permanent second body-regex arm, or a query that silently answers
"nothing here" for an old snippet and gets the helper written twice.

Refs #2083, milestone #232.
2026-07-27 23:09:37 -04:00

300 lines
11 KiB
Python

"""Real-Postgres integration test for the snippet location reverse lookup (#2083).
Runs only in the CI integration lane (real Postgres, schema built by
`alembic upgrade head`, which includes migration 0070's `notes.data` + GIN index).
This exercises what the unit mocks cannot:
- the jsonpath `@?` containment behind `_location_clause` — including
`starts with` for a path prefix, and the fact that parts must match within a
SINGLE `locations` entry;
- that the SQL dialect and the Python dialect (`location_matches`, used by the
semantic arm) return the SAME rows. Those two must never drift, or a snippet
is findable by wording and invisible by location;
- `backfill_snippet_data`, which is what stops a pre-0070 snippet from being
silently reported as "nothing recorded here."
"""
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import delete, select
from scribe.models import async_session, engine
from scribe.models.note import Note
from scribe.models.user import User
from scribe.services.knowledge import location_matches, location_parts
from scribe.services.snippets import (
SNIPPET_NOTE_TYPE,
backfill_snippet_data,
compose_body,
compose_data,
list_snippets,
snippet_fields,
)
pytestmark = pytest.mark.integration
@pytest_asyncio.fixture(autouse=True)
async def _dispose_engine():
# Per-loop pool: dispose after each test (see test_integration_db_maintenance).
yield
await engine.dispose()
def _loc(repo="", path="", symbol=""):
return {"repo": repo, "path": path, "symbol": symbol}
@pytest_asyncio.fixture
async def seeded():
"""A user with four snippets covering the cases the filter has to separate.
Returns (user_id, {label: note_id}).
"""
async with async_session() as s:
user = User(username="snippet_loc_itest")
s.add(user)
await s.flush()
def _snippet(name, locations, code="return 1"):
return Note(
user_id=user.id,
note_type=SNIPPET_NOTE_TYPE,
title=f"{name} — does a thing",
body=compose_body(code=code, language="python", locations=locations),
tags=["python", "snippet"],
data=compose_data(name=name, language="python", locations=locations),
)
nested = _snippet("nested", [_loc("Scribe", "frontend/src/lib/x.ts", "helper")])
sibling = _snippet("sibling", [_loc("Scribe", "frontend/srcmap.ts", "other")])
# Two locations, deliberately crossed: repo Scribe at src/a.py and repo
# Portal at src/b.py. repo=Scribe + path=src/b.py must NOT match it.
multi = _snippet(
"multi",
[_loc("Scribe", "src/a.py", "alpha"), _loc("Portal", "src/b.py", "beta")],
)
# No structured location at all — must never satisfy a location filter,
# and must not error the query either.
placeless = _snippet("placeless", [])
# A plain note (not a snippet) with no `data` — proves the jsonpath
# operator tolerates NULL rather than raising.
plain = Note(user_id=user.id, title="plain", body="no data here")
s.add_all([nested, sibling, multi, placeless, plain])
await s.commit()
ids = (
user.id,
{
"nested": nested.id,
"sibling": sibling.id,
"multi": multi.id,
"placeless": placeless.id,
"plain": plain.id,
},
)
yield ids
user_id = ids[0]
async with async_session() as s:
await s.execute(delete(Note).where(Note.user_id == user_id))
await s.execute(delete(User).where(User.id == user_id))
await s.commit()
async def _ids_for(user_id: int, **parts) -> set[int]:
items, total = await list_snippets(user_id, **parts)
assert total == len(items), "total must match the filtered page, not the whole list"
return {i["id"] for i in items}
@pytest.mark.asyncio
async def test_repo_filter_finds_every_snippet_recorded_in_it(seeded):
user_id, ids = seeded
found = await _ids_for(user_id, repo="Scribe")
assert found == {ids["nested"], ids["sibling"], ids["multi"]}
assert ids["placeless"] not in found
@pytest.mark.asyncio
async def test_exact_path_finds_only_that_file(seeded):
user_id, ids = seeded
assert await _ids_for(user_id, path="frontend/src/lib/x.ts") == {ids["nested"]}
@pytest.mark.asyncio
async def test_path_prefix_reaches_into_subdirectories(seeded):
user_id, ids = seeded
# ...and stops at the directory boundary: srcmap.ts is a sibling, not a child.
assert await _ids_for(user_id, path="frontend/src") == {ids["nested"]}
assert await _ids_for(user_id, path="frontend/src/") == {ids["nested"]}
assert await _ids_for(user_id, path="frontend") == {ids["nested"], ids["sibling"]}
@pytest.mark.asyncio
async def test_parts_must_match_within_one_location(seeded):
user_id, ids = seeded
assert await _ids_for(user_id, repo="Scribe", path="src/a.py") == {ids["multi"]}
# Same snippet, but repo and path belong to two different entries.
assert await _ids_for(user_id, repo="Scribe", path="src/b.py") == set()
assert await _ids_for(user_id, repo="Portal", path="src/b.py") == {ids["multi"]}
@pytest.mark.asyncio
async def test_symbol_filter_and_an_unrecorded_location(seeded):
user_id, ids = seeded
assert await _ids_for(user_id, symbol="helper") == {ids["nested"]}
assert await _ids_for(user_id, repo="NoSuchRepo") == set()
@pytest.mark.asyncio
async def test_no_location_filter_still_lists_everything(seeded):
"""The filter is opt-in — a plain browse must not lose the placeless snippet."""
found = await _ids_for(user_id=seeded[0])
ids = seeded[1]
assert {ids["nested"], ids["sibling"], ids["multi"], ids["placeless"]} <= found
assert ids["plain"] not in found # not a snippet
@pytest.mark.asyncio
async def test_keyword_arm_applies_the_location_filter(seeded):
"""Search AND place compose. The embedder is stubbed out so this isolates the
keyword (ILIKE) arm, which is the second place the SQL clause has to appear."""
user_id, ids = seeded
with patch(
"scribe.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[]),
):
assert await _ids_for(user_id, q="nested", repo="Scribe") == {ids["nested"]}
# Matches the query but not the place.
assert await _ids_for(user_id, q="nested", repo="Portal") == set()
@pytest.mark.asyncio
async def test_semantic_arm_applies_the_location_filter(seeded):
"""The semantic arm holds its candidates in memory, so it filters in Python.
A query that matches no title or body leaves only that arm in play."""
user_id, ids = seeded
async with async_session() as s:
rows = list(
(
await s.execute(
select(Note)
.where(Note.user_id == user_id)
.where(Note.note_type == SNIPPET_NOTE_TYPE)
)
)
.scalars()
.all()
)
candidates = [(0.9, n) for n in rows]
with patch(
"scribe.services.embeddings.semantic_search_notes",
AsyncMock(return_value=candidates),
):
# No keyword can match this, so every hit below came from the stub.
assert await _ids_for(user_id, q="zzzznomatch") == {
ids["nested"], ids["sibling"], ids["multi"], ids["placeless"],
}
assert await _ids_for(user_id, q="zzzznomatch", path="frontend/src") == {
ids["nested"],
}
assert await _ids_for(user_id, q="zzzznomatch", repo="Scribe", path="src/b.py") == set()
@pytest.mark.asyncio
async def test_sql_and_python_dialects_agree_on_the_same_rows(seeded):
"""The drift guard: whatever the SQL arm returns, the Python arm must too."""
user_id, _ids = seeded
async with async_session() as s:
rows = list(
(
await s.execute(
select(Note)
.where(Note.user_id == user_id)
.where(Note.note_type == SNIPPET_NOTE_TYPE)
)
)
.scalars()
.all()
)
by_id = {n.id: n.data for n in rows}
cases = [
{"repo": "Scribe"},
{"path": "frontend/src"},
{"path": "frontend/src/lib/x.ts"},
{"repo": "Scribe", "path": "src/a.py"},
{"repo": "Scribe", "path": "src/b.py"},
{"symbol": "beta"},
{"repo": "Scribe", "path": "frontend", "symbol": "helper"},
]
for case in cases:
sql_ids = await _ids_for(user_id, **case)
parts = location_parts(**case)
python_ids = {nid for nid, data in by_id.items() if location_matches(data, parts)}
assert sql_ids == python_ids, f"dialects disagree on {case}"
@pytest.mark.asyncio
async def test_backfill_makes_a_pre_0070_snippet_findable_by_location(seeded):
"""A snippet stored before migration 0070 has no `data`, so the location query
can't see it. Unfilled, it reads as "nothing recorded here" — the failure the
backfill exists to prevent."""
user_id, _ids = seeded
locations = [_loc("Legacy", "old/path/y.py", "legacy_helper")]
async with async_session() as s:
old = Note(
user_id=user_id,
note_type=SNIPPET_NOTE_TYPE,
title="legacy — recorded before the data column existed",
body=compose_body(code="return 2", language="python", locations=locations),
tags=["python", "snippet"],
data=None,
)
s.add(old)
await s.commit()
old_id = old.id
assert await _ids_for(user_id, repo="Legacy") == set()
filled = await backfill_snippet_data()
assert filled >= 1
assert await _ids_for(user_id, repo="Legacy") == {old_id}
assert await _ids_for(user_id, path="old/path") == {old_id}
# The mirror agrees with the body it was derived from, and the body is untouched.
async with async_session() as s:
row = (await s.execute(select(Note).where(Note.id == old_id))).scalar_one()
assert row.data["locations"] == locations
assert snippet_fields(row)["symbol"] == "legacy_helper"
assert "old/path/y.py" in row.body
@pytest.mark.asyncio
async def test_backfill_is_idempotent_and_settles_a_fieldless_snippet(seeded):
"""Second run fills nothing, and a snippet with no structured fields lands at
`{}` rather than staying NULL and being rescanned on every boot."""
user_id, _ids = seeded
async with async_session() as s:
bare = Note(
user_id=user_id,
note_type=SNIPPET_NOTE_TYPE,
title="",
body="",
tags=[],
data=None,
)
s.add(bare)
await s.commit()
bare_id = bare.id
await backfill_snippet_data()
assert await backfill_snippet_data() == 0
async with async_session() as s:
row = (await s.execute(select(Note).where(Note.id == bare_id))).scalar_one()
assert row.data == {}