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
+115 -15
View File
@@ -76,9 +76,43 @@ def compose_tags(language: str = "", tags: list[str] | None = None) -> list[str]
return out return out
def _location_line(repo: str = "", path: str = "", symbol: str = "") -> str: def _normalize_locations(locations: list[dict] | None) -> list[dict]:
parts = [p.strip() for p in (repo, path, symbol) if (p or "").strip()] """Clean a list of {repo,path,symbol} locations: strip fields, drop wholly
return " · ".join(f"`{p}`" for p in parts) empty entries, de-duplicate identical ones (order preserved). A merged
snippet carries several locations (one per call site); a fresh one carries
at most one."""
out: list[dict] = []
seen: set[tuple[str, str, str]] = set()
for loc in locations or []:
repo = (loc.get("repo") or "").strip()
path = (loc.get("path") or "").strip()
symbol = (loc.get("symbol") or "").strip()
if not (repo or path or symbol):
continue
key = (repo, path, symbol)
if key in seen:
continue
seen.add(key)
out.append({"repo": repo, "path": path, "symbol": symbol})
return out
def _location_str(loc: dict) -> str:
"""`repo` · `path` · `symbol` — only the non-empty parts."""
parts = [(loc.get(k) or "").strip() for k in ("repo", "path", "symbol")]
return " · ".join(f"`{p}`" for p in parts if p)
def _render_location_block(locations: list[dict]) -> str | None:
"""One `**Location:**` line for a single location; a `**Locations:**` bullet
list for several. None when there are none."""
locs = [loc for loc in locations if _location_str(loc)]
if not locs:
return None
if len(locs) == 1:
return f"**Location:** {_location_str(locs[0])}"
lines = "\n".join(f"- {_location_str(loc)}" for loc in locs)
return f"**Locations:**\n{lines}"
def compose_body( def compose_body(
@@ -90,17 +124,28 @@ def compose_body(
repo: str = "", repo: str = "",
path: str = "", path: str = "",
symbol: str = "", symbol: str = "",
locations: list[dict] | None = None,
) -> str: ) -> str:
"""Render structured fields into the snippet body markdown. Empty fields are """Render structured fields into the snippet body markdown. Empty fields are
omitted so the body stays clean.""" omitted so the body stays clean.
Locations: pass ``locations`` (a list of {repo,path,symbol}) for the general
multi-location case; the single ``repo``/``path``/``symbol`` params remain as
a back-compat shorthand for one location and are used only when ``locations``
is not given.
"""
if locations is None:
locations = [{"repo": repo, "path": path, "symbol": symbol}]
locs = _normalize_locations(locations)
header: list[str] = [] header: list[str] = []
if (when_to_use or "").strip(): if (when_to_use or "").strip():
header.append(f"**When to use:** {when_to_use.strip()}") header.append(f"**When to use:** {when_to_use.strip()}")
if (signature or "").strip(): if (signature or "").strip():
header.append(f"**Signature:** `{signature.strip()}`") header.append(f"**Signature:** `{signature.strip()}`")
loc = _location_line(repo, path, symbol) loc_block = _render_location_block(locs)
if loc: if loc_block:
header.append(f"**Location:** {loc}") header.append(loc_block)
fence_lang = (language or "").strip().lower() fence_lang = (language or "").strip().lower()
code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```" code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```"
if header: if header:
@@ -113,15 +158,33 @@ def compose_body(
_WHEN_RE = re.compile(r"^\*\*When to use:\*\*\s*(.+?)\s*$", re.MULTILINE) _WHEN_RE = re.compile(r"^\*\*When to use:\*\*\s*(.+?)\s*$", re.MULTILINE)
_SIG_RE = re.compile(r"^\*\*Signature:\*\*\s*(.+?)\s*$", re.MULTILINE) _SIG_RE = re.compile(r"^\*\*Signature:\*\*\s*(.+?)\s*$", re.MULTILINE)
_LOC_RE = re.compile(r"^\*\*Location:\*\*\s*(.+?)\s*$", re.MULTILINE) _LOC_RE = re.compile(r"^\*\*Location:\*\*\s*(.+?)\s*$", re.MULTILINE)
_LOCS_RE = re.compile(
r"^\*\*Locations:\*\*[ \t]*\n((?:[ \t]*-[ \t]*.+\n?)+)", re.MULTILINE
)
_CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL) _CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL)
def _parse_location_str(s: str) -> dict | None:
"""Parse a `repo` · `path` · `symbol` fragment back into a location dict, or
None if it's empty."""
raw = [p.strip().strip("`").strip() for p in (s or "").split("·")]
repo = raw[0] if len(raw) > 0 else ""
path = raw[1] if len(raw) > 1 else ""
symbol = raw[2] if len(raw) > 2 else ""
if not (repo or path or symbol):
return None
return {"repo": repo, "path": path, "symbol": symbol}
def parse_snippet_fields( def parse_snippet_fields(
title: str, body: str, tags: list[str] | None = None title: str, body: str, tags: list[str] | None = None
) -> dict: ) -> dict:
"""Recover structured fields from a snippet note. Tolerant by design: a field """Recover structured fields from a snippet note. Tolerant by design: a field
that isn't present comes back empty and this never raises, so a hand-edited that isn't present comes back empty and this never raises, so a hand-edited
body can't break the edit form.""" body can't break the edit form.
``locations`` is a list of {repo,path,symbol}; ``repo``/``path``/``symbol``
mirror the FIRST location for back-compat with the single-location callers."""
title = title or "" title = title or ""
body = body or "" body = body or ""
name, _, when_from_title = title.partition("") name, _, when_from_title = title.partition("")
@@ -133,6 +196,7 @@ def parse_snippet_fields(
"repo": "", "repo": "",
"path": "", "path": "",
"symbol": "", "symbol": "",
"locations": [],
"code": "", "code": "",
} }
@@ -142,11 +206,30 @@ def parse_snippet_fields(
m = _SIG_RE.search(body) m = _SIG_RE.search(body)
if m: if m:
fields["signature"] = m.group(1).strip().strip("`").strip() fields["signature"] = m.group(1).strip().strip("`").strip()
# Locations: prefer the multi-location `**Locations:**` bullet list, else the
# legacy single `**Location:**` line.
locations: list[dict] = []
m = _LOCS_RE.search(body)
if m:
for line in m.group(1).splitlines():
line = line.strip()
if line.startswith("-"):
loc = _parse_location_str(line[1:])
if loc:
locations.append(loc)
else:
m = _LOC_RE.search(body) m = _LOC_RE.search(body)
if m: if m:
loc_parts = [p.strip().strip("`").strip() for p in m.group(1).split("·")] loc = _parse_location_str(m.group(1))
for key, val in zip(("repo", "path", "symbol"), loc_parts): if loc:
fields[key] = val locations.append(loc)
fields["locations"] = locations
if locations:
fields["repo"] = locations[0]["repo"]
fields["path"] = locations[0]["path"]
fields["symbol"] = locations[0]["symbol"]
m = _CODE_RE.search(body) m = _CODE_RE.search(body)
if m: if m:
fields["language"] = m.group(1).strip() fields["language"] = m.group(1).strip()
@@ -241,29 +324,46 @@ async def update_snippet(
repo: str | None = None, repo: str | None = None,
path: str | None = None, path: str | None = None,
symbol: str | None = None, symbol: str | None = None,
locations: list[dict] | None = None,
tags: list[str] | None = None, tags: list[str] | None = None,
project_id: int | None = None, project_id: int | None = None,
): ):
"""Partial update: only fields passed (not None) change. Re-serializes the """Partial update: only fields passed (not None) change. Re-serializes the
merged field set back into title/body/tags. Returns the Note, or None if the merged field set back into title/body/tags. Returns the Note, or None if the
id isn't a snippet.""" id isn't a snippet.
Locations: ``locations`` replaces the whole set; else a legacy single
``repo``/``path``/``symbol`` overlays onto the first existing location; else
the existing locations are kept."""
note = await notes_svc.get_note(user_id, snippet_id) note = await notes_svc.get_note(user_id, snippet_id)
if note is None or note.note_type != SNIPPET_NOTE_TYPE: if note is None or note.note_type != SNIPPET_NOTE_TYPE:
return None return None
cur = parse_snippet_fields(note.title, note.body, note.tags) cur = parse_snippet_fields(note.title, note.body, note.tags)
overlay = { overlay = {
"name": name, "code": code, "language": language, "signature": signature, "name": name, "code": code, "language": language,
"when_to_use": when_to_use, "repo": repo, "path": path, "symbol": symbol, "signature": signature, "when_to_use": when_to_use,
} }
merged = {**cur, **{k: v for k, v in overlay.items() if v is not None}} merged = {**cur, **{k: v for k, v in overlay.items() if v is not None}}
if locations is not None:
merged_locations = _normalize_locations(locations)
elif repo is not None or path is not None or symbol is not None:
base = cur["locations"][0] if cur["locations"] else {"repo": "", "path": "", "symbol": ""}
merged_locations = _normalize_locations([{
"repo": repo if repo is not None else base["repo"],
"path": path if path is not None else base["path"],
"symbol": symbol if symbol is not None else base["symbol"],
}])
else:
merged_locations = cur["locations"]
fields: dict = { fields: dict = {
"title": compose_title(merged["name"], merged["when_to_use"]), "title": compose_title(merged["name"], merged["when_to_use"]),
"body": compose_body( "body": compose_body(
code=merged["code"], language=merged["language"], code=merged["code"], language=merged["language"],
signature=merged["signature"], when_to_use=merged["when_to_use"], signature=merged["signature"], when_to_use=merged["when_to_use"],
repo=merged["repo"], path=merged["path"], symbol=merged["symbol"], locations=merged_locations,
), ),
} }
# Recompute tags: keep any non-language, non-marker tags the note already had # Recompute tags: keep any non-language, non-marker tags the note already had
+60
View File
@@ -62,6 +62,66 @@ def test_parse_falls_back_to_tag_for_language():
assert got["language"] == "ruby" 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(): def test_snippet_to_dict_includes_parsed_fields():
class FakeNote: class FakeNote:
title = "debounce — rate-limit" title = "debounce — rate-limit"