feat: add context-aware wiki link resolution with source_path support (#527)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Drew Cain
2026-01-28 19:11:41 -06:00
committed by GitHub
parent 0b2080114b
commit 0023e736ab
7 changed files with 749 additions and 8 deletions
@@ -103,8 +103,12 @@ async def resolve_identifier(
resolution_method = "external_id" if entity else "search"
# If not found by external_id, try other resolution methods
# Pass source_path for context-aware resolution (prefers notes closer to source)
# Pass strict to control fuzzy search fallback (default False allows fuzzy matching)
if not entity:
entity = await link_resolver.resolve_link(data.identifier)
entity = await link_resolver.resolve_link(
data.identifier, source_path=data.source_path, strict=data.strict
)
if entity:
# Determine resolution method
if entity.permalink == data.identifier:
@@ -5,7 +5,7 @@ from typing import List, Optional, Sequence, Union, Any
from loguru import logger
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload
@@ -69,12 +69,21 @@ class EntityRepository(Repository[Entity]):
return await self.find_one(query)
async def get_by_title(self, title: str) -> Sequence[Entity]:
"""Get entity by title.
"""Get entities by title, ordered by shortest path first.
When multiple entities share the same title (in different folders),
returns them ordered by file_path length then alphabetically.
This provides "shortest path" resolution for duplicate titles.
Args:
title: Title of the entity to find
"""
query = self.select().where(Entity.title == title).options(*self.get_load_options())
query = (
self.select()
.where(Entity.title == title)
.order_by(func.length(Entity.file_path), Entity.file_path)
.options(*self.get_load_options())
)
result = await self.execute_query(query)
return list(result.scalars().all())
+12
View File
@@ -15,6 +15,9 @@ class EntityResolveRequest(BaseModel):
- Permalinks (e.g., "specs/search")
- Titles (e.g., "Search Specification")
- File paths (e.g., "specs/search.md")
When source_path is provided, resolution prefers notes closer to the source
(context-aware resolution for duplicate titles).
"""
identifier: str = Field(
@@ -23,6 +26,15 @@ class EntityResolveRequest(BaseModel):
min_length=1,
max_length=500,
)
source_path: Optional[str] = Field(
None,
description="Path of the source file containing the link (for context-aware resolution)",
max_length=500,
)
strict: bool = Field(
False,
description="If True, only exact matches are allowed (no fuzzy search fallback)",
)
class EntityResolveResponse(BaseModel):
+134 -4
View File
@@ -28,7 +28,11 @@ class LinkResolver:
self.search_service = search_service
async def resolve_link(
self, link_text: str, use_search: bool = True, strict: bool = False
self,
link_text: str,
use_search: bool = True,
strict: bool = False,
source_path: Optional[str] = None,
) -> Optional[Entity]:
"""Resolve a markdown link to a permalink.
@@ -36,12 +40,69 @@ class LinkResolver:
link_text: The link text to resolve
use_search: Whether to use search-based fuzzy matching as fallback
strict: If True, only exact matches are allowed (no fuzzy search fallback)
source_path: Optional path of the source file containing the link.
Used to prefer notes closer to the source (context-aware resolution).
"""
logger.trace(f"Resolving link: {link_text}")
logger.trace(f"Resolving link: {link_text} (source: {source_path})")
# Clean link text and extract any alias
clean_text, alias = self._normalize_link_text(link_text)
# --- Path Resolution ---
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
# for cross-platform compatibility. See entity_repository.py which normalizes
# paths using Path().as_posix(). This allows consistent path operations here.
# --- Relative Path Resolution ---
# Trigger: source_path is provided AND link contains "/"
# Why: Resolve paths like [[nested/deep-note]] relative to source folder first
# Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
if source_path and "/" in clean_text:
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
if source_folder:
# Construct relative path from source folder
relative_path = f"{source_folder}/{clean_text}"
# Try with .md extension
if not relative_path.endswith(".md"):
relative_path_md = f"{relative_path}.md"
entity = await self.entity_repository.get_by_file_path(relative_path_md)
if entity:
return entity
# Try as-is (already has extension or is a permalink)
entity = await self.entity_repository.get_by_file_path(relative_path)
if entity:
return entity
# When source_path is provided, use context-aware resolution:
# Check both permalink and title matches, prefer closest to source.
# Example: [[testing]] from folder/note.md prefers folder/testing.md
# over a root testing.md with permalink "testing".
if source_path:
# Gather all potential matches
candidates: list[Entity] = []
# Check permalink match
permalink_entity = await self.entity_repository.get_by_permalink(clean_text)
if permalink_entity:
candidates.append(permalink_entity)
# Check title matches
title_entities = await self.entity_repository.get_by_title(clean_text)
for entity in title_entities:
# Avoid duplicates (permalink match might also be in title matches)
if entity.id not in [c.id for c in candidates]:
candidates.append(entity)
if candidates:
if len(candidates) == 1:
return candidates[0]
else:
# Multiple candidates - pick closest to source
return self._find_closest_entity(candidates, source_path)
# Standard resolution (no source context): permalink first, then title
# 1. Try exact permalink match first (most efficient)
entity = await self.entity_repository.get_by_permalink(clean_text)
if entity:
@@ -51,7 +112,7 @@ class LinkResolver:
# 2. Try exact title match
found = await self.entity_repository.get_by_title(clean_text)
if found:
# Return first match if there are duplicates (consistent behavior)
# Return first match (shortest path) if no source context
entity = found[0]
logger.debug(f"Found title match: {entity.title}")
return entity
@@ -108,7 +169,7 @@ class LinkResolver:
if text.startswith("[[") and text.endswith("]]"):
text = text[2:-2]
# Handle Obsidian-style aliases (format: [[actual|alias]])
# Handle wiki link aliases (format: [[actual|alias]])
alias = None
if "|" in text:
text, alias = text.split("|", 1)
@@ -119,3 +180,72 @@ class LinkResolver:
text = text.strip()
return text, alias
def _find_closest_entity(self, entities: list[Entity], source_path: str) -> Entity:
"""Find the entity closest to the source file path.
Context-aware resolution: prefer notes in the same folder or closer in hierarchy.
Proximity Scoring Algorithm:
- Priority 0: Same folder as source (best match)
- Priority 1-N: Ancestor folders (N = levels up from source)
- Priority 100+N: Descendant folders (N = levels down, deprioritized)
- Priority 1000: Completely unrelated paths (least preferred)
- Ties are broken by shortest absolute path (consistent behavior)
Args:
entities: List of entities with the same title
source_path: Path of the file containing the link
Returns:
The entity closest to the source path
"""
# Extract source folder (everything before the last /)
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
def path_proximity(entity: Entity) -> Tuple[int, int]:
"""Return (proximity_score, path_length) for sorting.
Lower is better for both values.
"""
entity_path = entity.file_path
entity_folder = entity_path.rsplit("/", 1)[0] if "/" in entity_path else ""
# Trigger: entity is in the same folder as source
# Why: same-folder notes are most contextually relevant
# Outcome: priority = 0 (best), ties broken by shortest path
if entity_folder == source_folder:
return (0, len(entity_path))
# Trigger: entity is in an ancestor folder of source
# e.g., source is "a/b/c/file.md", entity is "a/b/note.md" -> ancestor
# Why: ancestors are contextually relevant (shared parent context)
# Outcome: priority = levels_up (1, 2, 3...), closer ancestors preferred
if source_folder.startswith(entity_folder + "/") if entity_folder else source_folder:
# Count how many levels up
if entity_folder:
levels_up = source_folder.count("/") - entity_folder.count("/")
else:
# Root level
levels_up = source_folder.count("/") + 1
return (levels_up, len(entity_path))
# Trigger: entity is in a descendant folder of source
# e.g., source is "a/file.md", entity is "a/b/c/note.md" -> descendant
# Why: descendants are less contextually relevant than ancestors
# Outcome: priority = 100 + levels_down, significantly deprioritized
if entity_folder.startswith(source_folder + "/") if source_folder else entity_folder:
if source_folder:
levels_down = entity_folder.count("/") - source_folder.count("/")
else:
# Source is at root
levels_down = entity_folder.count("/") + 1
return (100 + levels_down, len(entity_path))
# Trigger: entity is in a completely unrelated path
# Why: no folder relationship means minimal contextual relevance
# Outcome: priority = 1000, only selected if no related paths exist
return (1000, len(entity_path))
# Sort by proximity (lower is better), then by path length (shorter is better)
return min(entities, key=path_proximity)
+54
View File
@@ -52,6 +52,60 @@ async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url)
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project_url):
"""Test that resolve uses strict mode - no fuzzy search fallback.
This ensures wiki links only resolve to exact matches (permalink, title, or path),
not to similar-sounding entities via fuzzy search.
"""
# Create an entity with a specific name
entity_data = {
"title": "link-test",
"folder": "testing",
"content": "A test note",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
assert response.status_code == 200
# Try to resolve "nonexistent" - should NOT fuzzy match to "link-test"
resolve_data = {"identifier": "nonexistent"}
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
# Must return 404, not a fuzzy match to "link-test"
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_resolve_identifier_with_source_path_no_fuzzy_match(client: AsyncClient, v2_project_url):
"""Test that context-aware resolution also uses strict mode.
Even with source_path for context-aware resolution, nonexistent
links should return 404, not fuzzy match to nearby entities.
"""
# Create entities in a folder structure
entity_data = {
"title": "link-test",
"folder": "testing/nested",
"content": "A nested test note",
}
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
assert response.status_code == 200
# Try to resolve "nonexistent" with source_path context
# Should NOT fuzzy match to "link-test" in the same or nearby folder
resolve_data = {
"identifier": "nonexistent",
"source_path": "testing/nested/other-note.md",
}
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
# Must return 404, not a fuzzy match
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_entity_by_id(client: AsyncClient, test_graph, v2_project_url, entity_repository):
"""Test getting an entity by its external_id (UUID)."""
@@ -456,6 +456,66 @@ async def test_get_by_title(entity_repository: EntityRepository, session_maker):
assert len(found) == 2
@pytest.mark.asyncio
async def test_get_by_title_returns_shortest_path_first(
entity_repository: EntityRepository, session_maker
):
"""Test that duplicate titles are returned with shortest path first.
When multiple entities share the same title in different folders,
the one with the shortest file path should be returned first.
This provides consistent, predictable link resolution.
"""
async with db.scoped_session(session_maker) as session:
# Create entities with same title but different path lengths
# Insert in reverse order to ensure we're testing ordering, not insertion order
entities = [
Entity(
project_id=entity_repository.project_id,
title="My Note",
entity_type="note",
permalink="archive/old/2024/my-note",
file_path="archive/old/2024/My Note.md", # longest path
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
project_id=entity_repository.project_id,
title="My Note",
entity_type="note",
permalink="docs/my-note",
file_path="docs/My Note.md", # medium path
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
project_id=entity_repository.project_id,
title="My Note",
entity_type="note",
permalink="my-note",
file_path="My Note.md", # shortest path (root)
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
]
session.add_all(entities)
await session.flush()
# Get all entities with title "My Note"
found = await entity_repository.get_by_title("My Note")
# Should return all 3
assert len(found) == 3
# Should be ordered by path length (shortest first)
assert found[0].file_path == "My Note.md" # shortest
assert found[1].file_path == "docs/My Note.md" # medium
assert found[2].file_path == "archive/old/2024/My Note.md" # longest
@pytest.mark.asyncio
async def test_get_by_file_path(entity_repository: EntityRepository, session_maker):
"""Test getting an entity by title."""
+472
View File
@@ -357,3 +357,475 @@ async def test_duplicate_title_handling_in_strict_mode(link_resolver, test_entit
assert result is not None
# Should return the first match (components/core-service based on test fixture order)
assert result.permalink == "components/core-service"
# ============================================================================
# Context-aware resolution tests (source_path parameter)
# ============================================================================
@pytest_asyncio.fixture
async def context_aware_entities(entity_repository):
"""Create entities for testing context-aware resolution.
Structure:
├── testing.md (title: "testing", root level)
├── main/
│ └── testing/
│ ├── testing.md (title: "testing", nested)
│ └── another-test.md (title: "another-test")
├── other/
│ └── testing.md (title: "testing", different branch)
└── deep/
└── nested/
└── folder/
└── note.md (title: "note")
"""
entities = []
now = datetime.now(timezone.utc)
project_id = entity_repository.project_id
# Root level testing.md
e1 = await entity_repository.add(
EntityModel(
title="testing",
entity_type="note",
content_type="text/markdown",
file_path="testing.md",
permalink="testing",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e1)
# main/testing/testing.md
e2 = await entity_repository.add(
EntityModel(
title="testing",
entity_type="note",
content_type="text/markdown",
file_path="main/testing/testing.md",
permalink="main/testing/testing",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e2)
# main/testing/another-test.md
e3 = await entity_repository.add(
EntityModel(
title="another-test",
entity_type="note",
content_type="text/markdown",
file_path="main/testing/another-test.md",
permalink="main/testing/another-test",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e3)
# other/testing.md
e4 = await entity_repository.add(
EntityModel(
title="testing",
entity_type="note",
content_type="text/markdown",
file_path="other/testing.md",
permalink="other/testing",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e4)
# deep/nested/folder/note.md
e5 = await entity_repository.add(
EntityModel(
title="note",
entity_type="note",
content_type="text/markdown",
file_path="deep/nested/folder/note.md",
permalink="deep/nested/folder/note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e5)
# deep/note.md (for ancestor testing)
e6 = await entity_repository.add(
EntityModel(
title="note",
entity_type="note",
content_type="text/markdown",
file_path="deep/note.md",
permalink="deep/note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e6)
# note.md at root (for ancestor testing)
e7 = await entity_repository.add(
EntityModel(
title="note",
entity_type="note",
content_type="text/markdown",
file_path="note.md",
permalink="note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e7)
return entities
@pytest_asyncio.fixture
async def context_link_resolver(entity_repository, search_service, context_aware_entities):
"""Create LinkResolver instance with context-aware test data.
Note: We don't index entities for search because these tests focus on
exact title/permalink matching, not fuzzy search. The entities are
database-only records (no files on disk).
"""
return LinkResolver(entity_repository, search_service)
@pytest.mark.asyncio
async def test_source_path_same_folder_preference(context_link_resolver):
"""Test that links prefer notes in the same folder as the source."""
# From main/testing/another-test.md, [[testing]] should find main/testing/testing.md
result = await context_link_resolver.resolve_link(
"testing",
source_path="main/testing/another-test.md"
)
assert result is not None
assert result.file_path == "main/testing/testing.md"
@pytest.mark.asyncio
async def test_source_path_from_root_prefers_root(context_link_resolver):
"""Test that links from root-level notes prefer root-level matches."""
# From root-note.md, [[testing]] should find testing.md (root level)
result = await context_link_resolver.resolve_link(
"testing",
source_path="some-root-note.md"
)
assert result is not None
assert result.file_path == "testing.md"
@pytest.mark.asyncio
async def test_source_path_different_branch_prefers_closest(context_link_resolver):
"""Test resolution when source is in a different branch of the folder tree."""
# From other/testing.md, [[testing]] should find other/testing.md (same folder)
# Wait, other/testing.md IS the testing note in that folder, so this tests self-reference
# Let's test from a hypothetical other/different.md
result = await context_link_resolver.resolve_link(
"testing",
source_path="other/different.md"
)
assert result is not None
# Should find other/testing.md since it's in the same folder
assert result.file_path == "other/testing.md"
@pytest.mark.asyncio
async def test_source_path_ancestor_preference(context_link_resolver):
"""Test that closer ancestors are preferred over distant ones."""
# From deep/nested/folder/note.md, [[note]] with multiple "note" titles
# should prefer the closest ancestor match
# First verify there are multiple "note" entities
# deep/nested/folder/note.md, deep/note.md, note.md
# From deep/nested/folder/some-file.md, [[note]] should prefer:
# 1. deep/nested/folder/note.md (same folder) - but that's the note itself
# Let's say we're linking from a different file in that folder
result = await context_link_resolver.resolve_link(
"note",
source_path="deep/nested/folder/other-file.md"
)
assert result is not None
# Should find deep/nested/folder/note.md (same folder)
assert result.file_path == "deep/nested/folder/note.md"
@pytest.mark.asyncio
async def test_source_path_parent_folder_preference(context_link_resolver):
"""Test that parent folder is preferred when no same-folder match exists."""
# From deep/nested/folder/x.md where there's no "common" in same folder,
# but there's one in deep/nested/ - should prefer closer ancestor
# For this test, let's check that from deep/nested/other/file.md,
# [[note]] finds deep/note.md (ancestor) rather than note.md (root)
result = await context_link_resolver.resolve_link(
"note",
source_path="deep/nested/other/file.md"
)
assert result is not None
# No note.md in deep/nested/other/, so should find deep/note.md (closest ancestor)
# Actually deep/nested/folder/note.md might be considered... let me think
# deep/nested/other/file.md -> ancestors are deep/nested/, deep/, root
# Siblings/cousins like deep/nested/folder/ are NOT ancestors
# So should find deep/note.md
assert result.file_path == "deep/note.md"
@pytest.mark.asyncio
async def test_source_path_no_context_falls_back_to_shortest_path(context_link_resolver):
"""Test that without source_path, resolution falls back to shortest path."""
# Without source_path, should use standard resolution (permalink first, then title)
result = await context_link_resolver.resolve_link("testing")
assert result is not None
# Should get the one with shortest path or matching permalink
# "testing" matches permalink "testing" of root testing.md
assert result.file_path == "testing.md"
@pytest.mark.asyncio
async def test_source_path_unique_title_ignores_context(context_link_resolver):
"""Test that unique titles resolve correctly regardless of source_path."""
# "another-test" only exists in one place
result = await context_link_resolver.resolve_link(
"another-test",
source_path="other/some-file.md" # Different folder
)
assert result is not None
assert result.file_path == "main/testing/another-test.md"
@pytest.mark.asyncio
async def test_source_path_with_permalink_conflict(context_link_resolver):
"""Test that same-folder title match beats permalink match from different folder."""
# Root testing.md has permalink "testing"
# main/testing/testing.md has title "testing"
# From main/testing/another-test.md, [[testing]] should prefer the same-folder match
# even though there's a permalink match at root
result = await context_link_resolver.resolve_link(
"testing",
source_path="main/testing/another-test.md"
)
assert result is not None
# Should prefer same-folder title match over root permalink match
assert result.file_path == "main/testing/testing.md"
@pytest.mark.asyncio
async def test_find_closest_entity_same_folder(context_link_resolver, context_aware_entities):
"""Test _find_closest_entity helper with same folder match."""
# Get entities with title "testing"
testing_entities = [e for e in context_aware_entities if e.title == "testing"]
assert len(testing_entities) == 3 # root, main/testing, other
closest = context_link_resolver._find_closest_entity(
testing_entities,
"main/testing/another-test.md"
)
assert closest.file_path == "main/testing/testing.md"
@pytest.mark.asyncio
async def test_find_closest_entity_ancestor_preference(context_link_resolver, context_aware_entities):
"""Test _find_closest_entity prefers closer ancestors."""
# Get entities with title "note"
note_entities = [e for e in context_aware_entities if e.title == "note"]
assert len(note_entities) == 3 # deep/nested/folder, deep, root
# From deep/nested/other/file.md, should prefer deep/note.md over note.md
closest = context_link_resolver._find_closest_entity(
note_entities,
"deep/nested/other/file.md"
)
assert closest.file_path == "deep/note.md"
@pytest.mark.asyncio
async def test_find_closest_entity_root_source(context_link_resolver, context_aware_entities):
"""Test _find_closest_entity when source is at root."""
testing_entities = [e for e in context_aware_entities if e.title == "testing"]
# From root level, should prefer root testing.md
closest = context_link_resolver._find_closest_entity(
testing_entities,
"some-root-file.md"
)
assert closest.file_path == "testing.md"
@pytest.mark.asyncio
async def test_nonexistent_link_with_source_path(context_link_resolver):
"""Test that non-existent links return None even with source_path."""
result = await context_link_resolver.resolve_link(
"does-not-exist",
source_path="main/testing/another-test.md"
)
assert result is None
# ============================================================================
# Relative path resolution tests
# ============================================================================
@pytest_asyncio.fixture
async def relative_path_entities(entity_repository):
"""Create entities for testing relative path resolution.
Structure:
├── testing/
│ ├── link-test.md (source file for testing)
│ └── nested/
│ └── deep-note.md (target for relative path)
├── nested/
│ └── deep-note.md (different deep-note at root level)
└── other/
└── file.md
"""
entities = []
now = datetime.now(timezone.utc)
project_id = entity_repository.project_id
# testing/link-test.md (source file)
e1 = await entity_repository.add(
EntityModel(
title="link-test",
entity_type="note",
content_type="text/markdown",
file_path="testing/link-test.md",
permalink="testing/link-test",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e1)
# testing/nested/deep-note.md (relative target)
e2 = await entity_repository.add(
EntityModel(
title="deep-note",
entity_type="note",
content_type="text/markdown",
file_path="testing/nested/deep-note.md",
permalink="testing/nested/deep-note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e2)
# nested/deep-note.md (absolute path target)
e3 = await entity_repository.add(
EntityModel(
title="deep-note",
entity_type="note",
content_type="text/markdown",
file_path="nested/deep-note.md",
permalink="nested/deep-note",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e3)
# other/file.md
e4 = await entity_repository.add(
EntityModel(
title="file",
entity_type="note",
content_type="text/markdown",
file_path="other/file.md",
permalink="other/file",
created_at=now,
updated_at=now,
project_id=project_id,
)
)
entities.append(e4)
return entities
@pytest_asyncio.fixture
async def relative_path_resolver(entity_repository, search_service, relative_path_entities):
"""Create LinkResolver instance with relative path test data."""
return LinkResolver(entity_repository, search_service)
@pytest.mark.asyncio
async def test_relative_path_resolution_from_subfolder(relative_path_resolver):
"""Test that [[nested/deep-note]] from testing/link-test.md resolves to testing/nested/deep-note.md."""
# From testing/link-test.md, [[nested/deep-note]] should resolve to testing/nested/deep-note.md
result = await relative_path_resolver.resolve_link(
"nested/deep-note",
source_path="testing/link-test.md"
)
assert result is not None
assert result.file_path == "testing/nested/deep-note.md"
@pytest.mark.asyncio
async def test_relative_path_falls_back_to_absolute(relative_path_resolver):
"""Test that if relative path doesn't exist, falls back to absolute resolution."""
# From other/file.md, [[nested/deep-note]] should resolve to nested/deep-note.md (absolute)
# because other/nested/deep-note.md doesn't exist
result = await relative_path_resolver.resolve_link(
"nested/deep-note",
source_path="other/file.md"
)
assert result is not None
assert result.file_path == "nested/deep-note.md"
@pytest.mark.asyncio
async def test_relative_path_without_source_uses_absolute(relative_path_resolver):
"""Test that without source_path, paths are resolved as absolute."""
# Without source_path, [[nested/deep-note]] should resolve to nested/deep-note.md
result = await relative_path_resolver.resolve_link("nested/deep-note")
assert result is not None
assert result.file_path == "nested/deep-note.md"
@pytest.mark.asyncio
async def test_relative_path_from_root_falls_through(relative_path_resolver):
"""Test that paths from root-level files don't try relative resolution."""
# From root-file.md (no folder), [[nested/deep-note]] should resolve to nested/deep-note.md
result = await relative_path_resolver.resolve_link(
"nested/deep-note",
source_path="root-file.md"
)
assert result is not None
assert result.file_path == "nested/deep-note.md"
@pytest.mark.asyncio
async def test_simple_link_no_slash_skips_relative_resolution(relative_path_resolver):
"""Test that links without '/' don't trigger relative path resolution."""
# [[deep-note]] should use context-aware title matching, not relative paths
result = await relative_path_resolver.resolve_link(
"deep-note",
source_path="testing/link-test.md"
)
assert result is not None
# Should find testing/nested/deep-note.md via title match with same-folder preference
# Actually both have title "deep-note", so it should prefer the one closer to source
# testing/nested/ is not the same folder as testing/, but it's closer than nested/
# The context-aware resolution will pick the closest match
assert result.file_path == "testing/nested/deep-note.md"