Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/basic_memory/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,18 +177,22 @@ def dump_frontmatter(post: frontmatter.Post) -> str:
"""
Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.

This function ensures that tags are formatted as YAML lists instead of JSON arrays:
This function ensures that:
1. Tags are formatted as YAML lists instead of JSON arrays
2. String values are properly quoted to handle special characters (colons, etc.)

Good (Obsidian compatible):
---
title: "L2 Governance Core (Split: Core)"
tags:
- system
- overview
- reference
---

Bad (current behavior):
Bad (causes parsing errors):
---
title: L2 Governance Core (Split: Core) # Unquoted colon breaks YAML
tags: ["system", "overview", "reference"]
---

Expand All @@ -203,8 +207,13 @@ def dump_frontmatter(post: frontmatter.Post) -> str:
return post.content

# Serialize YAML with block style for lists
# SafeDumper automatically quotes values with special characters (colons, etc.)
yaml_str = yaml.dump(
post.metadata, sort_keys=False, allow_unicode=True, default_flow_style=False
post.metadata,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
Dumper=yaml.SafeDumper
)

# Construct the final markdown with frontmatter
Expand Down
73 changes: 0 additions & 73 deletions tests/sync/test_sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1651,79 +1651,6 @@ async def mock_sync_markdown_file(path, new):
assert entity is not None


@pytest.mark.asyncio
@pytest.mark.skip("flaky on ci tests")
async def test_circuit_breaker_tracks_multiple_files(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that circuit breaker tracks multiple failing files independently."""
from unittest.mock import patch

project_dir = project_config.home

# Create multiple files with valid markdown
await create_test_file(
project_dir / "file1.md",
"""
---
type: knowledge
---
# File 1
Content 1
""",
)
await create_test_file(
project_dir / "file2.md",
"""
---
type: knowledge
---
# File 2
Content 2
""",
)
await create_test_file(
project_dir / "file3.md",
"""
---
type: knowledge
---
# File 3
Content 3
""",
)

# Mock to make file1 and file2 fail, but file3 succeed
original_sync_markdown_file = sync_service.sync_markdown_file

async def mock_sync_markdown_file(path, new):
if "file1.md" in path or "file2.md" in path:
raise ValueError(f"Failure for {path}")
# file3 succeeds - use real implementation
return await original_sync_markdown_file(path, new)

with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
# Fail 3 times for file1 and file2 (file3 succeeds each time)
await force_full_scan(sync_service)
await sync_service.sync(project_dir) # Fail count: file1=1, file2=1
await touch_file(project_dir / "file1.md") # Touch to trigger incremental scan
await touch_file(project_dir / "file2.md") # Touch to trigger incremental scan
await force_full_scan(sync_service)
await sync_service.sync(project_dir) # Fail count: file1=2, file2=2
await touch_file(project_dir / "file1.md") # Touch to trigger incremental scan
await touch_file(project_dir / "file2.md") # Touch to trigger incremental scan
report3 = await sync_service.sync(project_dir) # Fail count: file1=3, file2=3, now skipped

# Both files should be skipped on third sync
assert len(report3.skipped_files) == 2
skipped_paths = {f.path for f in report3.skipped_files}
assert "file1.md" in skipped_paths
assert "file2.md" in skipped_paths

# Verify file3 is not in failures dict
assert "file3.md" not in sync_service._file_failures


@pytest.mark.asyncio
async def test_circuit_breaker_handles_checksum_computation_failure(
sync_service: SyncService, project_config: ProjectConfig
Expand Down
97 changes: 97 additions & 0 deletions tests/utils/test_frontmatter_obsidian_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,100 @@ def test_roundtrip_compatibility():
assert parsed_post.metadata["title"] == original_post.metadata["title"]
assert parsed_post.metadata["tags"] == original_post.metadata["tags"]
assert parsed_post.metadata["type"] == original_post.metadata["type"]


def test_title_with_colon():
"""Test that titles with colons are properly quoted and don't break YAML parsing."""
post = frontmatter.Post("Test content")
post.metadata["title"] = "L2 Governance Core (Split: Core)"
post.metadata["type"] = "note"

result = dump_frontmatter(post)

# PyYAML uses single quotes for values with special characters
assert "title: 'L2 Governance Core (Split: Core)'" in result

# Should be parseable back
parsed_post = frontmatter.loads(result)
assert parsed_post.metadata["title"] == "L2 Governance Core (Split: Core)"


def test_title_starting_with_word_and_colon():
"""Test that titles starting with word and colon are properly quoted."""
post = frontmatter.Post("Test content")
post.metadata["title"] = "Governance: Rootkeeper Manifest-Diff Prompt"
post.metadata["type"] = "note"

result = dump_frontmatter(post)

# PyYAML auto-quotes values with colons (uses single quotes by default)
assert "title: 'Governance: Rootkeeper Manifest-Diff Prompt'" in result

# Should be parseable back
parsed_post = frontmatter.loads(result)
assert parsed_post.metadata["title"] == "Governance: Rootkeeper Manifest-Diff Prompt"


def test_multiple_colons_in_title():
"""Test that titles with multiple colons are properly quoted."""
post = frontmatter.Post("Test content")
post.metadata["title"] = "API: HTTP: Response Codes: Overview"
post.metadata["type"] = "note"

result = dump_frontmatter(post)

# PyYAML auto-quotes values with colons
assert "title: 'API: HTTP: Response Codes: Overview'" in result

# Should be parseable back
parsed_post = frontmatter.loads(result)
assert parsed_post.metadata["title"] == "API: HTTP: Response Codes: Overview"


def test_other_special_characters_in_title():
"""Test that titles with other special YAML characters are properly quoted."""
special_chars_titles = [
"Title with @ symbol",
"Title with # hashtag",
"Title with & ampersand",
"Title with * asterisk",
"Title [with brackets]",
"Title {with braces}",
"Title with | pipe",
"Title with > greater",
]

for title in special_chars_titles:
post = frontmatter.Post("Test content")
post.metadata["title"] = title
post.metadata["type"] = "note"

result = dump_frontmatter(post)

# Should be parseable without errors
parsed_post = frontmatter.loads(result)
assert parsed_post.metadata["title"] == title


def test_all_string_values_quoted():
"""Test that string values with special characters are automatically quoted."""
post = frontmatter.Post("Test content")
post.metadata["title"] = "Test: Title"
post.metadata["permalink"] = "test-permalink"
post.metadata["type"] = "note"
post.metadata["custom_field"] = "value: with colon"

result = dump_frontmatter(post)

# PyYAML auto-quotes values with special chars, leaves simple values unquoted
assert "title: 'Test: Title'" in result # Has colon, gets quoted
assert "permalink: test-permalink" in result # Simple value, no quotes
assert "type: note" in result # Simple value, no quotes
assert "custom_field: 'value: with colon'" in result # Has colon, gets quoted

# Should be parseable back correctly
parsed_post = frontmatter.loads(result)
assert parsed_post.metadata["title"] == "Test: Title"
assert parsed_post.metadata["permalink"] == "test-permalink"
assert parsed_post.metadata["type"] == "note"
assert parsed_post.metadata["custom_field"] == "value: with colon"
Loading