Skip to content

fix(core): resolve Markdown path links against the note at resolution time - #1577

Merged
phernandez merged 1 commit into
mainfrom
fix/1514-path-links-resolve-at-resolution-time
Sep 16, 2026
Merged

phernandez merged 1 commit into
mainfrom
fix/1514-path-links-resolve-at-resolution-time

Conversation

@phernandez

@phernandez phernandez commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the #1514 follow-up where Markdown path links were resolved at parse time from the file's location on disk. Cloud's S3FileService.read_entity_content parsed a note from a temp file outside the project root, Path.relative_to raised, and the read failed (cloud main CI red; cloud side fixed in basicmachines-co/basic-memory-cloud#2086).

The root cause was in core: a Markdown link was the only relation whose meaning depended on where the parser read the bytes. Wikilinks are recorded as authored and resolved later against database state. This PR makes path links behave the same way, and gives path-shaped wikilinks ([[../x.md]], [[./x.md]]) the same exact-file resolution.

What changes

  • Parser (markdown/entity_parser.py, markdown/path_links.py): parse(content) no longer takes a source_path. A Markdown link is stored as the path its author wrote: ../guides/Guide.md, ./same.md (a bare same.md gets the ./ mark so the stored target says it is a path, not a title), or a rooted /root.md. URLs, mailto:/file: links, fragment-only links, backslash or null-byte paths, and folder hrefs (./, docs/) create no relation.
  • Resolution (services/link_resolver.py, services/bulk_link_resolver.py): both resolvers detect a path target (/, ./, ../) after wikilink normalization and resolve it with resolve_project_path(target, source_path) against the note's own project path. Exact file only. No title, permalink, alias, or cross-project fallback. A path that climbs past the project root resolves to nothing.
  • Background resolution (indexing/relation_resolution.py, indexing/models.py): the batch resolver contract now takes RelationTargetRequest(link_text, source_path). Identity targets carry no source and resolve once for every note; path targets are keyed by their source note, so ./Guide.md from a/ and from b/ resolve to two different files in one pass. Source paths are fetched with one find_by_ids call only when the batch contains a path target.
  • Self-edges (services/note_preparation.py): the write-time self-link check resolves the authored path against the note's own path, so [self](Self.md) in notes/Self.md still resolves to itself.
  • Docs: docs/MARKDOWN_RELATIONS.md describes the stored form and resolution rule; CHANGELOG.md gets a Bug Fixes entry.

Behaviour change for stored data

Relation rows for Markdown links now store the authored path instead of the parser-resolved rooted path. Existing rows are rewritten on the note's next edit or reindex, as the docs already state for these relations. Unresolved rooted rows (/x.md) from before this change resolve the same way they did.

Tests

  • tests/markdown/test_path_links.py: tables for markdown_link_path, resolve_project_path, is_path_target; parser test proves the authored form is stored and that parsing needs no filesystem location.
  • tests/services/test_markdown_path_resolution.py: rooted, ./, ../, no-source, past-root, and [[../x.md|alias]] cases through LinkResolver.
  • tests/services/test_bulk_link_resolver.py: the same authored path resolves to a different file from each source note; a path never falls back to a title.
  • tests/indexing/test_relation_resolution.py: path targets keyed per source note in one batch, identity targets still deduplicated.
  • test-int/mcp/test_markdown_path_relations_integration.py: end-to-end through write_note, including a path-shaped wikilink and a self-link.

Verification run locally:

just fast-check                      # ruff fix, format, ty: clean
just test-unit-sqlite                # 7678 passed, 48 skipped
uv run pytest tests/markdown tests/services/test_bulk_link_resolver.py \
  tests/services/test_link_resolver.py tests/services/test_markdown_path_resolution.py \
  tests/indexing tests/index tests/sync \
  test-int/mcp/test_markdown_path_relations_integration.py   # 1248 passed

Cloud follow-up

After this merges, cloud re-pins basic-memory to the new SHA. The in-memory parse in cloud #2086 stays correct either way; with this change the parser no longer needs a project-relative path at all.

Closes the parse-time half of #1514.

🤖 Generated with Claude Code

https://claude.ai/code/session_019YW9ysxugGGBCNEGzsxtFV

… time

A Markdown link was the only relation whose meaning depended on where the
parser read the bytes: the parser derived the note's project path from the
filesystem path with relative_to and turned the href into a rooted path at
parse time. Content parsed from anywhere outside the project root raised
(cloud reads a note from object storage into a temp file), and any other
temporary location gave every link the wrong base.

Wikilinks are recorded as authored and resolved later against database
state. Path links now behave the same way:

- The parser stores the path as the author wrote it: ../guides/Guide.md,
  ./same.md (a bare same.md gets the ./ mark so the stored target says it
  is a path and not a title), or a rooted /root.md. parse() takes no
  source path.
- Both resolvers detect a path target (/, ./, ../) after wikilink
  normalization and resolve it against the note's own project path with
  resolve_project_path. Exact file only; no title, permalink, alias or
  cross-project fallback; a path that climbs past the root names nothing.
  Path-shaped wikilinks such as [[../x.md]] follow the same rule.
- Background resolution keys targets by RelationTargetRequest(link_text,
  source_path). Identity targets carry no source and resolve once for
  every note; path targets are keyed by their source note, so ./Guide.md
  from two folders resolves to two files in one pass. Source paths are
  loaded with one find_by_ids call only when a batch contains a path
  target.
- The write-time self-link check resolves the authored path against the
  note's own path.

Existing relation rows for Markdown links are rewritten to the authored
form on the note's next edit or reindex, as the docs already state.

Refs #1514

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YW9ysxugGGBCNEGzsxtFV
Signed-off-by: phernandez <paul@basicmachines.co>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@phernandez
phernandez merged commit 3bf2d52 into main Sep 16, 2026
35 checks passed
@phernandez
phernandez deleted the fix/1514-path-links-resolve-at-resolution-time branch September 16, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant