fix(markdown): resolve wikilinks vault-wide when sibling resolution misses (#2875)

An Obsidian-style [[wikilink]] resolves to a note anywhere in the vault by basename, but
resolution only tried sibling files, so cross-folder links were silently lost. Add a
vault-wide fallback that fires only when relative/sibling resolution misses, with a
deterministic tiebreak on ambiguous basenames (shallowest path, then lexicographic
root-relative path) and a once-per-scan index so it stays O(N).
This commit is contained in:
BaeHyunJae
2026-08-20 15:41:30 +01:00
committed by safishamsi
parent ef282c19fa
commit b3cd74a0cb
3 changed files with 233 additions and 6 deletions
+2 -1
View File
@@ -46,7 +46,7 @@ from graphify.extractors.fortran import _cpp_preprocess, extract_fortran # noqa
from graphify.extractors.go import _GO_PREDECLARED_FUNCS, extract_go # noqa: F401
from graphify.extractors.json_config import extract_json # noqa: F401
from graphify.extractors.commonlisp import extract_commonlisp # noqa: F401
from graphify.extractors.markdown import extract_markdown # noqa: F401
from graphify.extractors.markdown import extract_markdown, _MD_LINK_INDEX_CACHE # noqa: F401
from graphify.extractors.ocaml import extract_ocaml # noqa: F401
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
@@ -5426,6 +5426,7 @@ def extract(
# Workspace package manifests/globs can change during watch or repeated extraction.
_WORKSPACE_PACKAGE_CACHE.clear()
_XAML_CSHARP_CLASS_CACHE.clear()
_MD_LINK_INDEX_CACHE.clear()
# Infer a common root for cache keys (use first diverging segment, not sum of all matches)
try:
+116 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import re
import os
import unicodedata
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id
@@ -90,7 +91,99 @@ def _parse_frontmatter_fallback(fm_lines: list[str]) -> dict:
out[key] = value.strip('"\'')
return out
def _resolve_markdown_link(raw: str, source_dir: Path) -> "Path | None":
# Vault-wide wikilink fallback. Obsidian-style vaults resolve a [[wikilink]]
# by vault-global filename lookup, not relative to the linking file, so a note
# in a subfolder linking [[hub]] reaches a root-level hub.md. The lexical
# sibling resolution below is correct when the target exists beside the
# source; when it does not, the reference edge kept an id derived from a
# nonexistent path, matched no node, and silently dropped at build time — the
# same lost-edge failure #1376 fixed for hub docs, reintroduced for every
# cross-folder vault link. The fallback consults a per-scan-root index of
# document files and only fires when the lexically resolved path does not
# exist, so non-vault corpora and resolvable relative links are untouched.
#
# Keyed by resolved scan root. extract() clears it at the start of each run
# (beside _WORKSPACE_PACKAGE_CACHE) so a serial rerun in one process sees
# files created since the last run; parallel workers are fresh processes.
_MD_LINK_INDEX_CACHE: "dict[str, dict[str, list[tuple[int, str, Path]]]]" = {}
def _active_scan_root() -> "Path | None":
"""The scan root of the extraction in flight, or None outside extract().
_safe_extract_with_xaml_root sets this for every extraction despite its
XAML-era name; a direct extract_markdown() call has no root and the
fallback stays off.
"""
import graphify.extract as _extract
return getattr(_extract, "_XAML_ACTIVE_EXTRACT_ROOT", None)
def _nfc(s: str) -> str:
# Filesystems disagree on Unicode normalization (macOS decomposes, others
# do not); a link typed in NFC must still find a file listed in NFD.
return unicodedata.normalize("NFC", s)
def _build_link_index(root: Path) -> "dict[str, list[tuple[int, str, Path]]]":
"""Index every linkable document under *root* by NFC-normalized basename.
Each entry maps basename -> [(depth, root-relative posix path, absolute
path)]. Directories in detect._SKIP_DIRS and dot-directories are pruned —
the same corpus boundary the scanner draws, and Obsidian itself does not
index dot-folders.
"""
from graphify.detect import _SKIP_DIRS
index: dict[str, list[tuple[int, str, Path]]] = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(
d for d in dirnames if not d.startswith(".") and d not in _SKIP_DIRS
)
for fname in filenames:
if Path(fname).suffix.lower() not in _MD_LINKABLE_EXTS:
continue
abs_path = Path(dirpath) / fname
rel = os.path.relpath(str(abs_path), str(root)).replace("\\", "/")
index.setdefault(_nfc(fname), []).append(
(rel.count("/"), _nfc(rel), abs_path)
)
return index
def _vault_lookup(target: str, root: Path) -> "Path | None":
"""Resolve *target* (a normalized wikilink path, `.md` already appended)
against the corpus under *root*, or None when nothing matches.
A bare name matches by basename; a path-qualified target
(``folder/name.md``) must match its full segment suffix. Ties break to the
shallowest match, then lexicographically — mirroring Obsidian, where a
root-level file wins a bare-link name collision — so resolution is
deterministic regardless of walk order.
"""
root_key = str(root)
index = _MD_LINK_INDEX_CACHE.get(root_key)
if index is None:
try:
index = _build_link_index(root)
except OSError:
index = {}
_MD_LINK_INDEX_CACHE[root_key] = index
parts = _nfc(target.replace("\\", "/")).split("/")
candidates = index.get(parts[-1])
if not candidates:
return None
suffix = "/".join(parts)
matches = [
c for c in candidates
if c[1] == suffix or c[1].endswith("/" + suffix)
]
if not matches:
return None
return min(matches)[2]
def _resolve_markdown_link(raw: str, source_dir: Path,
wikilink: bool = False) -> "Path | None":
"""Resolve a markdown link target to the absolute path of a sibling document.
Returns the resolved (normalized, not necessarily existing) path when the
@@ -102,6 +195,12 @@ def _resolve_markdown_link(raw: str, source_dir: Path) -> "Path | None":
The anchor fragment (``#section``) and query (``?x=1``) are stripped before
resolution so ``./repo.md#setup`` resolves to the same node as ``./repo.md``.
Extension-less targets (typical of wikilinks) are treated as sibling ``.md``.
With ``wikilink=True``, a target whose lexically resolved path does not
exist is retried as a vault-global lookup across the active scan root (see
_vault_lookup) — Obsidian's own resolution order for wikilinks. Inline and
reference-style links keep pure relative semantics: for them a missing
relative target is an authoring error, not an alternate link convention.
"""
target = raw.strip()
if not target:
@@ -122,7 +221,19 @@ def _resolve_markdown_link(raw: str, source_dir: Path) -> "Path | None":
candidate = Path(target)
if not candidate.is_absolute():
candidate = source_dir / candidate
return Path(os.path.normpath(str(candidate)))
resolved = Path(os.path.normpath(str(candidate)))
if wikilink and not Path(target).is_absolute():
try:
missing = not resolved.is_file()
except OSError:
missing = False
if missing:
scan_root = _active_scan_root()
if scan_root is not None:
hit = _vault_lookup(target, scan_root)
if hit is not None:
return Path(os.path.normpath(str(hit)))
return resolved
def extract_markdown(path: Path) -> dict:
"""Extract structural nodes and edges from a Markdown file.
@@ -208,8 +319,8 @@ def extract_markdown(path: Path) -> dict:
# same sibling many times yields one edge, not N (keeps weights meaningful).
linked_targets: set[str] = set()
def add_link(raw: str, line: int) -> None:
resolved = _resolve_markdown_link(raw, source_dir)
def add_link(raw: str, line: int, wikilink: bool = False) -> None:
resolved = _resolve_markdown_link(raw, source_dir, wikilink=wikilink)
if resolved is None:
return
# Build the target ID with the SAME recipe as the target file's own
@@ -260,7 +371,7 @@ def extract_markdown(path: Path) -> dict:
for m in _MD_INLINE_LINK_RE.finditer(line_text):
add_link(m.group(1), line_num)
for m in _MD_WIKILINK_RE.finditer(line_text):
add_link(m.group(1), line_num)
add_link(m.group(1), line_num, wikilink=True)
ref_def = _MD_REF_DEF_RE.match(line_text)
if ref_def:
add_link(ref_def.group(1), line_num)
+115
View File
@@ -2626,6 +2626,121 @@ def test_markdown_link_edges_resolve_to_real_nodes(tmp_path):
assert len(index_refs) == 3, f"hub doc under-connected: {index_refs}"
def _vault_extract(vault, paths):
"""Serial extract() anchored at *vault*, returning (node_ids, ref_edges,
page-id lookup keyed by vault-relative posix path)."""
from graphify.extract import extract
res = extract(sorted(paths), cache_root=vault, root=vault, parallel=False)
node_ids = {n["id"] for n in res["nodes"]}
refs = [e for e in res["edges"] if e["relation"] == "references"]
by_sf = {n["source_file"]: n["id"] for n in res["nodes"]
if n.get("node_kind") == "page"}
def page_id(path):
return by_sf[path.relative_to(vault).as_posix()]
return node_ids, refs, page_id
def test_markdown_wikilink_vault_fallback(tmp_path):
"""A subfolder note's [[wikilink]] to a root-level doc resolves vault-wide
when sibling resolution misses, instead of silently dropping at build."""
vault = tmp_path / "vault"
(vault / "log").mkdir(parents=True)
(vault / "hub.md").write_text("# Hub\nContent.\n")
(vault / "log" / "entry.md").write_text("# Entry\nSee [[hub]].\n")
node_ids, refs, page_id = _vault_extract(
vault, [vault / "hub.md", vault / "log" / "entry.md"])
entry_id = page_id(vault / "log" / "entry.md")
hub_id = page_id(vault / "hub.md")
assert any(e["source"] == entry_id and e["target"] == hub_id
for e in refs), f"vault link lost: {refs}"
for e in refs:
assert e["target"] in node_ids, f"link target is a ghost node: {e}"
def test_markdown_wikilink_fallback_path_qualified(tmp_path):
"""[[folder/name]] from a subfolder matches on the full segment suffix."""
vault = tmp_path / "vault"
(vault / "log").mkdir(parents=True)
(vault / "Materials").mkdir()
(vault / "Materials" / "ref.md").write_text("# Ref\n")
(vault / "log" / "entry.md").write_text("See [[Materials/ref]].\n")
_, refs, page_id = _vault_extract(
vault, [vault / "Materials" / "ref.md", vault / "log" / "entry.md"])
assert any(e["target"] == page_id(vault / "Materials" / "ref.md")
for e in refs), f"path-qualified vault link lost: {refs}"
def test_markdown_wikilink_fallback_root_wins(tmp_path):
"""On a bare-name collision the shallowest match wins — Obsidian resolves
a bare wikilink to the root-level file over a same-named subfolder file."""
vault = tmp_path / "vault"
(vault / "log").mkdir(parents=True)
(vault / "sub").mkdir()
(vault / "hub.md").write_text("# Root hub\n")
(vault / "sub" / "hub.md").write_text("# Sub hub\n")
(vault / "log" / "entry.md").write_text("See [[hub]].\n")
_, refs, page_id = _vault_extract(
vault, [vault / "hub.md", vault / "sub" / "hub.md",
vault / "log" / "entry.md"])
entry_id = page_id(vault / "log" / "entry.md")
targets = {e["target"] for e in refs if e["source"] == entry_id}
assert targets == {page_id(vault / "hub.md")}, (
f"expected the root-level hub only, got {targets}")
def test_markdown_wikilink_sibling_still_wins(tmp_path):
"""An existing sibling target keeps lexical resolution — the fallback only
fires when the resolved path is missing, so #1376 behavior is unchanged."""
vault = tmp_path / "vault"
(vault / "sub").mkdir(parents=True)
(vault / "hub.md").write_text("# Root hub\n")
(vault / "sub" / "hub.md").write_text("# Sub hub\n")
(vault / "sub" / "entry.md").write_text("See [[hub]].\n")
_, refs, page_id = _vault_extract(
vault, [vault / "hub.md", vault / "sub" / "hub.md",
vault / "sub" / "entry.md"])
entry_id = page_id(vault / "sub" / "entry.md")
targets = {e["target"] for e in refs if e["source"] == entry_id}
assert targets == {page_id(vault / "sub" / "hub.md")}, (
f"sibling must shadow the vault-wide match, got {targets}")
def test_markdown_inline_link_keeps_relative_semantics(tmp_path):
"""Inline [text](missing.md) links get no vault fallback: a missing
relative target stays dangling exactly as before."""
vault = tmp_path / "vault"
(vault / "log").mkdir(parents=True)
(vault / "hub.md").write_text("# Hub\n")
(vault / "log" / "entry.md").write_text("See [hub](hub.md).\n")
node_ids, refs, page_id = _vault_extract(
vault, [vault / "hub.md", vault / "log" / "entry.md"])
entry_id = page_id(vault / "log" / "entry.md")
entry_refs = [e for e in refs if e["source"] == entry_id]
for e in entry_refs:
assert e["target"] != page_id(vault / "hub.md"), (
f"inline link must not resolve vault-wide: {e}")
def test_markdown_wikilink_fallback_unicode_normalization(tmp_path):
"""A wikilink typed in NFD finds a file named in NFC (and spaces survive):
filesystems disagree on Unicode normalization, the index must not."""
import unicodedata
vault = tmp_path / "vault"
(vault / "log").mkdir(parents=True)
name_nfc = unicodedata.normalize("NFC", "어휘 노트")
(vault / f"{name_nfc}.md").write_text("# Term\n")
name_nfd = unicodedata.normalize("NFD", name_nfc)
(vault / "log" / "entry.md").write_text(f"See [[{name_nfd}]].\n")
_, refs, page_id = _vault_extract(
vault, [vault / f"{name_nfc}.md", vault / "log" / "entry.md"])
entry_id = page_id(vault / "log" / "entry.md")
target_id = page_id(vault / f"{name_nfc}.md")
assert any(e["source"] == entry_id and e["target"] == target_id
for e in refs), f"NFD wikilink missed the NFC file: {refs}"
# ── Groovy ───────────────────────────────────────────────────────────────────