Skip to content

Commit cc68ceb

Browse files
committed
fix(core): fail prepend on malformed frontmatter
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 5171793 commit cc68ceb

File tree

2 files changed

+101
-25
lines changed

2 files changed

+101
-25
lines changed

src/basic_memory/services/entity_service.py

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,33 +1262,30 @@ def insert_relative_to_section(
12621262
def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
12631263
"""Prepend content after frontmatter, preserving frontmatter structure."""
12641264

1265-
# Check if file has frontmatter
1265+
# Trigger: the note starts with frontmatter delimiters.
1266+
# Why: prepend must preserve the existing YAML block and insert content into the body,
1267+
# not silently rewrite malformed metadata into a corrupted accepted note state.
1268+
# Outcome: valid frontmatter is preserved, and malformed frontmatter fails fast.
12661269
if has_frontmatter(current_content):
1267-
try:
1268-
# Parse and separate frontmatter from body
1269-
frontmatter_data = parse_frontmatter(current_content)
1270-
body_content = remove_frontmatter(current_content)
1271-
1272-
# Prepend content to the body
1273-
if content and not content.endswith("\n"):
1274-
new_body = content + "\n" + body_content
1275-
else:
1276-
new_body = content + body_content
1270+
# Parse and separate frontmatter from body. Parse errors are intentional caller-visible
1271+
# failures so prepare_edit_entity_content can reject unsafe accepted writes.
1272+
frontmatter_data = parse_frontmatter(current_content)
1273+
body_content = remove_frontmatter(current_content)
1274+
1275+
# Prepend content to the body
1276+
if content and not content.endswith("\n"):
1277+
new_body = content + "\n" + body_content
1278+
else:
1279+
new_body = content + body_content
12771280

1278-
# Reconstruct file with frontmatter + prepended body
1279-
yaml_fm = yaml.dump(frontmatter_data, sort_keys=False, allow_unicode=True)
1280-
return f"---\n{yaml_fm}---\n\n{new_body.strip()}"
1281+
# Reconstruct file with frontmatter + prepended body
1282+
yaml_fm = yaml.dump(frontmatter_data, sort_keys=False, allow_unicode=True)
1283+
return f"---\n{yaml_fm}---\n\n{new_body.strip()}"
12811284

1282-
except Exception as e: # pragma: no cover
1283-
logger.warning(
1284-
f"Failed to parse frontmatter during prepend: {e}"
1285-
) # pragma: no cover
1286-
# Fall back to simple prepend if frontmatter parsing fails # pragma: no cover
1287-
1288-
# No frontmatter or parsing failed - do simple prepend # pragma: no cover
1289-
if content and not content.endswith("\n"): # pragma: no cover
1290-
return content + "\n" + current_content # pragma: no cover
1291-
return content + current_content # pragma: no cover
1285+
# No frontmatter means prepend is a plain text edit.
1286+
if content and not content.endswith("\n"):
1287+
return content + "\n" + current_content
1288+
return content + current_content
12921289

12931290
async def move_entity(
12941291
self,

tests/services/test_entity_service_prepare.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from basic_memory.file_utils import parse_frontmatter
7+
from basic_memory.file_utils import ParseError, parse_frontmatter, remove_frontmatter
88
from basic_memory.schemas import Entity as EntitySchema
99

1010

@@ -128,3 +128,82 @@ async def test_prepare_edit_entity_content_matches_edit_entity_with_content(
128128
assert prepared.entity_fields["title"] == result.entity.title
129129
assert prepared.entity_fields["note_type"] == result.entity.note_type
130130
assert prepared.entity_fields["permalink"] == result.entity.permalink
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_prepare_edit_entity_content_prepend_preserves_valid_frontmatter(
135+
entity_service,
136+
file_service,
137+
) -> None:
138+
created = await entity_service.create_entity(
139+
EntitySchema(
140+
title="Prepared Prepend Frontmatter",
141+
directory="notes",
142+
note_type="note",
143+
content="---\nstatus: draft\ntags:\n - one\n---\nOriginal body",
144+
)
145+
)
146+
147+
current_content = await file_service.read_file_content(created.file_path)
148+
prepared = await entity_service.prepare_edit_entity_content(
149+
created,
150+
current_content,
151+
operation="prepend",
152+
content="Prepended line",
153+
)
154+
155+
assert parse_frontmatter(prepared.markdown_content) == {
156+
"title": "Prepared Prepend Frontmatter",
157+
"type": "note",
158+
"status": "draft",
159+
"tags": ["one"],
160+
"permalink": created.permalink,
161+
}
162+
assert remove_frontmatter(prepared.markdown_content) == "Prepended line\nOriginal body"
163+
164+
165+
@pytest.mark.asyncio
166+
async def test_prepare_edit_entity_content_prepend_fails_for_malformed_frontmatter(
167+
entity_service,
168+
) -> None:
169+
created = await entity_service.create_entity(
170+
EntitySchema(
171+
title="Prepared Prepend Parse Error",
172+
directory="notes",
173+
note_type="note",
174+
content="Original body",
175+
)
176+
)
177+
178+
malformed_content = "---\nstatus: [draft\n---\nOriginal body"
179+
180+
with pytest.raises(ParseError, match="Invalid YAML in frontmatter"):
181+
await entity_service.prepare_edit_entity_content(
182+
created,
183+
malformed_content,
184+
operation="prepend",
185+
content="Prepended line",
186+
)
187+
188+
189+
@pytest.mark.asyncio
190+
async def test_prepare_edit_entity_content_prepend_without_frontmatter_uses_simple_prepend(
191+
entity_service,
192+
) -> None:
193+
created = await entity_service.create_entity(
194+
EntitySchema(
195+
title="Prepared Prepend Simple",
196+
directory="notes",
197+
note_type="note",
198+
content="Original body",
199+
)
200+
)
201+
202+
prepared = await entity_service.prepare_edit_entity_content(
203+
created,
204+
"Original body",
205+
operation="prepend",
206+
content="Prepended line",
207+
)
208+
209+
assert prepared.markdown_content == "Prepended line\nOriginal body"

0 commit comments

Comments
 (0)