feat(scribe): multi-location snippet body convention (backward compatible)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m7s

Step 1 of the snippet-merge milestone (#231). A snippet that unifies N
found one-offs carries N locations (one per call site), so `location`
becomes a list. Ships on the body-convention — no migration, swappable to
the deferred `data` JSONB later (as #227 decision 5 anticipated).

- compose_body: renders `**Location:**` for a single location, a
  `**Locations:**` bullet list for several. Accepts a `locations` list;
  the single repo/path/symbol params remain as a one-location shorthand
  (create path + existing callers/tests unchanged).
- parse_snippet_fields: reads BOTH the new `**Locations:**` list block AND
  the legacy single `**Location:**` line (tolerant, never raises); returns
  a `locations` list and mirrors the first into repo/path/symbol for
  back-compat (rule #33).
- update_snippet: gains a `locations` param — replaces the whole set; else
  a legacy single triple overlays onto the first location; else kept.
- Tests: multi-location round-trip, singular-vs-plural label, legacy
  single-line parse, normalize dedup/empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
This commit is contained in:
2026-07-25 17:07:09 -04:00
parent d257c0fd67
commit eb400a521b
2 changed files with 176 additions and 16 deletions
+60
View File
@@ -62,6 +62,66 @@ def test_parse_falls_back_to_tag_for_language():
assert got["language"] == "ruby"
def test_compose_body_multi_location_renders_bullet_list():
body = s.compose_body(
code="x = 1",
locations=[
{"repo": "scribe", "path": "a.py", "symbol": "f"},
{"repo": "web", "path": "b.ts", "symbol": "g"},
],
)
assert "**Locations:**" in body
assert "- `scribe` · `a.py` · `f`" in body
assert "- `web` · `b.ts` · `g`" in body
assert "**Location:**" not in body.replace("**Locations:**", "")
def test_compose_body_single_location_via_list_uses_singular_label():
body = s.compose_body(
code="x = 1", locations=[{"repo": "scribe", "path": "a.py", "symbol": "f"}],
)
assert "**Location:** `scribe` · `a.py` · `f`" in body
assert "**Locations:**" not in body
def test_normalize_locations_drops_empty_and_dedups():
got = s._normalize_locations([
{"repo": "scribe", "path": "a.py", "symbol": "f"},
{"repo": "", "path": "", "symbol": ""}, # dropped (empty)
{"repo": "scribe", "path": "a.py", "symbol": "f"}, # dropped (dup)
{"repo": "web", "path": "", "symbol": ""},
])
assert got == [
{"repo": "scribe", "path": "a.py", "symbol": "f"},
{"repo": "web", "path": "", "symbol": ""},
]
def test_parse_round_trips_multi_location():
body = s.compose_body(
code="const x = 1", language="ts",
locations=[
{"repo": "scribe", "path": "a.ts", "symbol": "f"},
{"repo": "web", "path": "b.ts", "symbol": "g"},
],
)
got = s.parse_snippet_fields("n — u", body, ["ts", "snippet"])
assert got["locations"] == [
{"repo": "scribe", "path": "a.ts", "symbol": "f"},
{"repo": "web", "path": "b.ts", "symbol": "g"},
]
# repo/path/symbol mirror the first location for back-compat.
assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.ts", "f")
def test_parse_legacy_single_location_line_still_works():
# A body written by the pre-multi-location serializer.
body = "**Location:** `scribe` · `a.py` · `f`\n\n```py\nx = 1\n```\n"
got = s.parse_snippet_fields("n — u", body, None)
assert got["locations"] == [{"repo": "scribe", "path": "a.py", "symbol": "f"}]
assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.py", "f")
def test_snippet_to_dict_includes_parsed_fields():
class FakeNote:
title = "debounce — rate-limit"