Skip to content

Commit ec0b373

Browse files
committed
fix(cli): preserve unresolved paths and deterministic YAML sets
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 1aee8d2 commit ec0b373

2 files changed

Lines changed: 89 additions & 2 deletions

File tree

src/basic_memory/okf/render.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@
2424
from basic_memory.okf.validation import Document, parse_document
2525

2626

27+
class ExportDumper(yaml.SafeDumper):
28+
"""Retain YAML mapping order while making unordered sets deterministic."""
29+
30+
31+
def represent_set(dumper: ExportDumper, values: set[object]) -> yaml.nodes.MappingNode:
32+
ordered = sorted(values, key=lambda value: yaml.safe_dump(value, sort_keys=True))
33+
return dumper.represent_mapping("tag:yaml.org,2002:set", [(value, None) for value in ordered])
34+
35+
36+
ExportDumper.add_representer(set, represent_set)
37+
38+
2739
@dataclass(frozen=True)
2840
class ExportFile:
2941
path: str
@@ -107,6 +119,10 @@ def wikilink(state: StateInline, silent: bool) -> bool:
107119
# Explicit relative links bind to their source directory before semantic aliases.
108120
# Wikilink paths are literal identifiers, so URL decoding must round-trip them.
109121
relative = markdown_link_target(quote(target, safe="/"), source)
122+
# A root-relative URI with escaping dot segments could normalize to a real note.
123+
# Keep that unresolved reference literal rather than inventing a portable edge.
124+
if relative is None and ".." in PurePosixPath(target).parts:
125+
return False
110126
if (
111127
include_project
112128
and "/" in target
@@ -131,7 +147,15 @@ def wikilink(state: StateInline, silent: bool) -> bool:
131147
resolved = title_targets.get(target)
132148
if not rooted and resolved is None:
133149
resolved = targets.get(target)
134-
if not rooted and resolved is None and target not in ambiguous_aliases:
150+
if (
151+
not rooted
152+
and resolved is None
153+
and (
154+
target not in ambiguous_aliases
155+
or "/" in target
156+
or target.casefold().endswith(".md")
157+
)
158+
):
135159
# Forgiving filename spelling is a last resort after exact identities.
136160
candidates = ([relative] if relative and "/" in target else []) + [target]
137161
for candidate in candidates:
@@ -285,7 +309,9 @@ def render_bundle(snapshot: ExportSnapshot) -> tuple[ExportFile, ...]:
285309
permalinks=permalinks,
286310
title_targets=title_targets,
287311
)
288-
content = "---\n" + yaml.safe_dump(metadata, allow_unicode=True, sort_keys=False)
312+
content = "---\n" + yaml.dump(
313+
metadata, Dumper=ExportDumper, allow_unicode=True, sort_keys=False
314+
)
289315
content += "---\n" + body
290316
output.append(ExportFile(file.path, content.encode("utf-8")))
291317

tests/okf/test_okf.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,3 +736,64 @@ def test_permalink_compatibility_candidates_are_not_filename_aliases(test_projec
736736
)
737737
source = next(file.content for file in render_bundle(snapshot) if file.path == "source.md")
738738
assert b"[p/foo](/p/foo) and [foo](/foo.md)" in source
739+
740+
741+
def test_escaping_wikilink_stays_literal_instead_of_normalizing_to_a_real_note():
742+
body = "[[../../a.md]] and [[../a.md]]"
743+
assert convert_wikilinks(body, "folder/source.md", {"a.md": "a.md"}, "p") == (
744+
"[[../../a.md]] and [../a.md](/a.md)"
745+
)
746+
747+
748+
def test_ambiguous_slash_title_still_allows_exact_filename_inference(test_project):
749+
from basic_memory.models import Entity
750+
from basic_memory.services.bulk_link_resolver import ProjectEntityIdentityIndex
751+
752+
owner = Entity(title="p/foo", file_path="p/foo.md")
753+
index = ProjectEntityIdentityIndex.from_entities(
754+
test_project, [owner, Entity(title="p/foo", file_path="other.md")]
755+
)
756+
assert (
757+
index.resolve_strict(
758+
"p/foo", include_project_permalinks=True, workspace_permalink=None
759+
).entity
760+
is owner
761+
)
762+
snapshot = ExportSnapshot(
763+
"p",
764+
(
765+
ExportFile("p/foo.md", b"---\ntitle: p/foo\n---\n"),
766+
ExportFile("other.md", b"---\ntitle: p/foo\n---\n"),
767+
ExportFile("source.md", b"[[p/foo]]"),
768+
),
769+
)
770+
source = next(file.content for file in render_bundle(snapshot) if file.path == "source.md")
771+
assert b"[p/foo](/p/foo.md)" in source
772+
773+
774+
def test_yaml_sets_are_deterministic_across_processes():
775+
import os
776+
import subprocess
777+
import sys
778+
779+
source = b"---\ntype: note\ncustom: !!set {alpha: null, beta: null, gamma: null}\n---\n"
780+
expected = next(
781+
file.content.decode()
782+
for file in render_bundle(ExportSnapshot("p", (ExportFile("a.md", source),)))
783+
if file.path == "a.md"
784+
)
785+
script = (
786+
"from basic_memory.okf.render import ExportFile, ExportSnapshot, render_bundle\n"
787+
f"source = {source!r}\n"
788+
"print(next(f.content.decode() for f in render_bundle(ExportSnapshot('p', "
789+
"(ExportFile('a.md', source),))) if f.path == 'a.md'))\n"
790+
)
791+
outputs = [
792+
subprocess.check_output(
793+
[sys.executable, "-c", script], env={**os.environ, "PYTHONHASHSEED": seed}, text=True
794+
)
795+
for seed in ("1", "2")
796+
]
797+
assert outputs[0] == outputs[1] == expected + "\n"
798+
assert parse_document(outputs[0]).metadata["custom"] == {"alpha", "beta", "gamma"}
799+
assert outputs[0].index("type: note") < outputs[0].index("custom:")

0 commit comments

Comments
 (0)