mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 00:36:39 +00:00
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:
+2
-1
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user