Skip to content

Commit cbea50e

Browse files
phernandezclaude
andauthored
fix(core): stop minting typed relations from prose tails (#1291)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent db1e209 commit cbea50e

2 files changed

Lines changed: 131 additions & 29 deletions

File tree

src/basic_memory/markdown/plugins.py

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -145,41 +145,72 @@ def is_explicit_relation(token: Token) -> bool:
145145

146146
# Use token.tag which contains the actual content for test tokens, fallback to content
147147
content = (token.tag or token.content).strip()
148-
return "[[" in content and "]]" in content and parse_relation_type(content) is not None
148+
if "[[" not in content or "]]" not in content:
149+
return False
150+
return _parse_explicit_relation(content) is not None
149151

150152

151-
def parse_relation(token: Token) -> Dict[str, Any] | None:
152-
"""Extract relation parts from token."""
153-
# Remove bullet point if present
154-
# Use token.tag which contains the actual content for test tokens, fallback to content
155-
content = (token.tag or token.content).strip()
156-
153+
def _parse_explicit_relation(content: str) -> Dict[str, Any] | None:
154+
"""Parse ``type [[target]] (context)``, rejecting lines with a prose tail."""
157155
rel_type = parse_relation_type(content)
158156
if rel_type is None:
159157
return None
160158

161-
# Extract [[target]]
162-
target = None
163-
context = None
164-
165159
start = content.find("[[")
166160
end = content.find("]]", start + 2)
161+
if start == -1 or end == -1:
162+
return None
167163

168-
if start != -1 and end != -1:
169-
# Get target
170-
target = normalize_project_reference(content[start + 2 : end].strip())
171-
172-
# Look for context after
173-
after = content[end + 2 :].strip()
174-
if after.startswith("(") and after.endswith(")"):
175-
context = after[1:-1].strip() or None
176-
177-
if not target: # pragma: no cover
164+
target = normalize_project_reference(content[start + 2 : end].strip())
165+
if not target:
178166
return None
179167

168+
# Trigger: text follows the target that is not a single parenthesized context.
169+
# Why: an explicit relation line ends at its target or its (context). A prose
170+
# tail means the line is a sentence that happens to contain a wikilink; the
171+
# old behavior minted a junk type from the word before the link and silently
172+
# dropped the tail — including any further [[links]] in it — from the edge
173+
# (#1260).
174+
# Outcome: such lines fall through to inline links_to handling, which keeps
175+
# every wikilink on the line as an edge and the sentence intact as content.
176+
after = content[end + 2 :].strip()
177+
context = None
178+
if after:
179+
if not _is_single_parenthesized(after):
180+
return None
181+
context = after[1:-1].strip() or None
182+
180183
return {"type": rel_type, "target": target, "context": context}
181184

182185

186+
def _is_single_parenthesized(text: str) -> bool:
187+
"""Whether the text is one balanced ``(...)`` group and nothing more.
188+
189+
Checking only the first and last characters would accept
190+
``(primary) and [[Beta]] (secondary)`` as a single context and silently
191+
drop the Beta link — the corruption class the prose-tail rule exists to
192+
prevent — so the opening paren must close exactly at the final character.
193+
"""
194+
if not text.startswith("("):
195+
return False
196+
depth = 0
197+
for position, char in enumerate(text):
198+
if char == "(":
199+
depth += 1
200+
elif char == ")":
201+
depth -= 1
202+
if depth == 0:
203+
return position == len(text) - 1
204+
return False
205+
206+
207+
def parse_relation(token: Token) -> Dict[str, Any] | None:
208+
"""Extract relation parts from token."""
209+
# Use token.tag which contains the actual content for test tokens, fallback to content
210+
content = (token.tag or token.content).strip()
211+
return _parse_explicit_relation(content)
212+
213+
183214
def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
184215
"""Find wiki-style links in regular content."""
185216
relations = []

tests/markdown/test_relation_edge_cases.py

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,24 @@ def test_malformed_links():
4242
tokens = md.parse("- type ]]Target[[")
4343
assert not any(t.meta and "relations" in t.meta for t in tokens)
4444

45-
# Nested brackets
45+
# Nested brackets: the tail after the first ]] is not a (context), so the
46+
# line is not an explicit relation; inline handling depth-matches the link.
4647
tokens = md.parse("- type [[Outer [[Inner]] ]]")
4748
token = next(t for t in tokens if t.type == "inline")
48-
rel = parse_relation(token)
49-
assert rel is not None
50-
assert "Outer" in rel["target"]
49+
assert parse_relation(token) is None
50+
assert all(r["type"] == "links_to" for r in token.meta["relations"])
5151

5252

5353
def test_context_handling():
5454
"""Test handling of contexts."""
5555
md = MarkdownIt().use(relation_plugin)
5656

57-
# Unclosed context
57+
# Unclosed context is a prose tail, not a context: the line falls back to
58+
# an inline link instead of minting a typed edge with the tail dropped.
5859
tokens = md.parse("- type [[Target]] (unclosed")
5960
token = next(t for t in tokens if t.type == "inline")
60-
rel = parse_relation(token)
61-
assert rel is not None
62-
assert rel["context"] is None
61+
assert parse_relation(token) is None
62+
assert token.meta["relations"] == [{"type": "links_to", "target": "Target", "context": None}]
6363

6464
# Multiple parens
6565
tokens = md.parse("- type [[Target]] (with (nested) parens)")
@@ -98,6 +98,77 @@ def test_inline_relations():
9898
assert len(token.meta["relations"]) == 3
9999

100100

101+
def test_prose_tail_falls_back_to_inline_link():
102+
"""A sentence containing a wikilink must not mint a typed relation (#1260).
103+
104+
An explicit relation line ends at its target or its (context); trailing
105+
prose means the line is ordinary writing, and the old behavior both minted
106+
a junk type from the word before the link and silently dropped the tail.
107+
"""
108+
md = MarkdownIt().use(relation_plugin)
109+
110+
for src in [
111+
"- Added [[Target Note]] to the roster",
112+
"- Calls [[Target Note]] every Sunday",
113+
'- "multi word type" [[Target Note]] trailing prose',
114+
"- type [[Target Note]] (context) and more",
115+
]:
116+
tokens = md.parse(src)
117+
token = next(t for t in tokens if t.type == "inline")
118+
assert parse_relation(token) is None, src
119+
assert token.meta["relations"] == [
120+
{"type": "links_to", "target": "Target Note", "context": None}
121+
], src
122+
123+
124+
def test_prose_tail_keeps_every_wikilink_in_the_tail():
125+
"""Falling back to inline handling preserves links the old path dropped."""
126+
md = MarkdownIt().use(relation_plugin)
127+
128+
# The old explicit path minted `relates_to -> A` and lost B's edge entirely.
129+
tokens = md.parse("- relates_to [[Alpha]] and [[Beta]]")
130+
token = next(t for t in tokens if t.type == "inline")
131+
assert parse_relation(token) is None
132+
assert {r["target"] for r in token.meta["relations"]} == {"Alpha", "Beta"}
133+
assert all(r["type"] == "links_to" for r in token.meta["relations"])
134+
135+
tokens = md.parse("- Links: [[Alpha]], [[Beta]]")
136+
token = next(t for t in tokens if t.type == "inline")
137+
assert {r["target"] for r in token.meta["relations"]} == {"Alpha", "Beta"}
138+
139+
# A context-looking tail whose opening paren closes before the end is prose:
140+
# accepting `(primary) and [[Beta]] (secondary)` as one context would drop
141+
# the Beta link — the corruption class this rule exists to prevent.
142+
tokens = md.parse("- relates_to [[Alpha]] (primary) and [[Beta]] (secondary)")
143+
token = next(t for t in tokens if t.type == "inline")
144+
assert parse_relation(token) is None
145+
assert {r["target"] for r in token.meta["relations"]} == {"Alpha", "Beta"}
146+
assert all(r["type"] == "links_to" for r in token.meta["relations"])
147+
148+
149+
def test_explicit_relation_forms_still_parse():
150+
"""Hand-authored relation shapes keep their types after the #1260 fix."""
151+
md = MarkdownIt().use(relation_plugin)
152+
153+
expected = {
154+
"- spouse_of [[Target Note]]": ("spouse_of", None),
155+
"- requires [[Target Note]] (because reasons)": ("requires", "because reasons"),
156+
'- "multi word type" [[Target Note]] (context)': ("multi word type", "context"),
157+
}
158+
for src, (rel_type, context) in expected.items():
159+
tokens = md.parse(src)
160+
token = next(t for t in tokens if t.type == "inline")
161+
rel = parse_relation(token)
162+
assert rel == {"type": rel_type, "target": "Target Note", "context": context}, src
163+
164+
# Known limitation, pinned deliberately: a single capitalized word with no
165+
# tail is indistinguishable by shape from a hand-authored type ("Requires"),
166+
# so it still mints. The grammar policy for this case is tracked in #1260.
167+
tokens = md.parse("- Mother [[Target Note]]")
168+
token = next(t for t in tokens if t.type == "inline")
169+
assert parse_relation(token) == {"type": "Mother", "target": "Target Note", "context": None}
170+
171+
101172
def test_unicode_targets():
102173
"""Test handling of Unicode in targets."""
103174
md = MarkdownIt().use(relation_plugin)

0 commit comments

Comments
 (0)