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
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:
+115
-15
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user