From 2416d4222f193e8d4d79821b799569c6b6553ec7 Mon Sep 17 00:00:00 2001 From: Ph4nt0m Date: Mon, 3 Aug 2026 16:34:19 +0900 Subject: [PATCH] fix(markdown): resolve [[wikilinks]] vault-wide, not sibling-only (#2413) Obsidian resolves [[name]] against the whole vault by filename, but _resolve_markdown_link only ever tried source_dir / name. In a vault that splits notes into type folders (Concepts/, Entities/, Guides/, Maps/), every cross-folder wikilink therefore missed: no target_file stamp was written, the #2169 remap pass had nothing to canonicalize, and build_from_json dropped the edge. Same failure shape as #2211, but the target is not in the sibling directory at all, so the stamp is never produced in the first place. Sibling resolution still runs first and still wins, so path-qualified links ([text](./other.md), which are relative by spec) keep exactly their current semantics and never enter the new path. Only a wikilink whose sibling guess missed is stamped with its bare name; extract()'s post-pass is the first point that knows every in-root file, so it builds a stem index there and repoints those edges. Ambiguity resolves deterministically: the linking note's own folder first (mirrors Obsidian), then shallowest path, then lexicographic. The stamp is transient and popped in the same pass, so graph.json shape is unchanged, and a link to a file that exists nowhere still dangles as before. Measured on a 499-note Obsidian vault: valid references 1,293 -> 2,323 against an authored baseline of 2,339 (55.3% -> 99.3% recall); graph edges 7,623 -> 8,470. The residual 16 are links to files that genuinely do not exist. Tests: 4 new cases covering the stamp, the path-qualified non-regression, cross-folder resolution with a still-dangling broken link, and same-folder preference on an ambiguous stem. --- graphify/extract.py | 40 +++++++++++++++ graphify/extractors/markdown.py | 23 +++++++-- tests/test_languages.py | 87 +++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 30f31d329..e61bd30a3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4968,6 +4968,46 @@ def _portable_out_of_root_sf(p: Path) -> str: stem_forms[path.resolve()] = ( new_id, [old_pref_abs, old_pref, new_id] ) + # Obsidian-style wikilink resolution (#2211 follow-up). ``[[name]]`` is a + # NAME reference resolved against the whole vault, not a relative path; + # markdown.py can only guess a sibling because a per-file extractor has no + # corpus view. Any guess that missed is stamped with ``wikilink_name``, and + # this is the first point that knows every in-root file, so repoint those + # edges here. Path-qualified links never carry the stamp, so relative-link + # semantics are untouched. On an incremental run the index only covers the + # changed batch plus stamped targets, exactly like the target_file remap + # above -- a full scan resolves the rest. + _wl_edges = [e for e in all_edges if e.get("wikilink_name")] + if _wl_edges and root is not None: + _DOC_EXTS = {".md", ".mdx", ".qmd", ".markdown", ".rst", ".txt"} + name_index: dict[str, list[Path]] = {} + for _p in remap_paths: + if _p.suffix.lower() not in _DOC_EXTS: + continue + try: + _rel = _p.resolve().relative_to(root) + except (OSError, RuntimeError, ValueError): + continue + name_index.setdefault(_rel.stem.lower(), []).append(_rel) + for _e in _wl_edges: + _cands = name_index.get(str(_e.pop("wikilink_name")).lower()) + if not _cands: + continue # genuinely broken link: stays dangling, as before + _src_dir = None + _sf = _e.get("source_file") + if _sf: + try: + _src_dir = Path(_sf).resolve().relative_to(root).parent + except (OSError, RuntimeError, ValueError): + _src_dir = None + # Deterministic pick: same folder as the linking note first (mirrors + # Obsidian), then the shallowest path, then lexicographic. + _chosen = min( + _cands, + key=lambda c: (c.parent != _src_dir, len(c.parts), str(c)), + ) + _e["target"] = _file_node_id(_chosen) + if id_remap: for n in all_nodes: if n.get("id") in id_remap: diff --git a/graphify/extractors/markdown.py b/graphify/extractors/markdown.py index e1b24409a..abb3a3c35 100644 --- a/graphify/extractors/markdown.py +++ b/graphify/extractors/markdown.py @@ -95,12 +95,15 @@ def add_node(nid: str, label: str, line: int, file_type: str = "document") -> No def add_edge(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0, - target_file: "str | None" = None) -> None: + target_file: "str | None" = None, + wikilink_name: "str | None" = None) -> None: edge = {"source": src, "target": tgt, "relation": relation, "confidence": confidence, "source_file": str_path, "source_location": f"L{line}", "weight": weight} if target_file is not None: edge["target_file"] = target_file + if wikilink_name is not None: + edge["wikilink_name"] = wikilink_name edges.append(edge) file_nid = _make_id(str(path)) @@ -111,7 +114,7 @@ def add_edge(src: str, tgt: str, relation: str, line: int, # 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: + def add_link(raw: str, line: int, *, wikilink: bool = False) -> None: resolved = _resolve_markdown_link(raw, source_dir) if resolved is None: return @@ -138,7 +141,19 @@ def add_link(raw: str, line: int) -> None: target_file = str(resolved) except OSError: pass - add_edge(file_nid, tgt_nid, "references", line, target_file=target_file) + # Obsidian resolves ``[[name]]`` against the WHOLE vault by filename, not + # against the linking note's directory. A note-type vault (Concepts/, + # Entities/, Guides/, Maps/ ...) therefore links across folders with bare + # names, and sibling-only resolution leaves every one of those dangling. + # When the sibling guess missed, stamp the bare name so extract()'s + # post-pass can repoint the edge against the corpus index. Path-qualified + # markdown links (``[t](./other.md)``) are unaffected: they are relative + # by spec, so sibling resolution is already correct for them. + wl_name = None + if target_file is None and wikilink: + wl_name = resolved.stem + add_edge(file_nid, tgt_nid, "references", line, + target_file=target_file, wikilink_name=wl_name) # Track heading stack for nesting: [(level, nid), ...] heading_stack: list[tuple[int, str]] = [] @@ -164,7 +179,7 @@ def add_link(raw: str, line: int) -> None: 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) diff --git a/tests/test_languages.py b/tests/test_languages.py index aff689d2a..7c0fdac03 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -2241,6 +2241,93 @@ def test_markdown_contains_edges(): assert len(contains_edges) >= 5, f"expected >= 5 contains edges, got {len(contains_edges)}" +def test_markdown_wikilink_stamps_name_when_sibling_missing(tmp_path): + """A [[name]] with no sibling match carries a wikilink_name stamp. + + Obsidian resolves ``[[name]]`` against the whole vault by filename, so a + per-file extractor cannot resolve a cross-folder link on its own. It stamps + the bare name instead, and extract()'s post-pass repoints it. + """ + src_dir = tmp_path / "Concepts" + src_dir.mkdir() + note = src_dir / "alpha.md" + note.write_text("# Alpha\n\nSee [[beta]] and [[alpha-sibling]].\n", encoding="utf-8") + (src_dir / "alpha-sibling.md").write_text("# Sibling\n", encoding="utf-8") + + r = extract_markdown(note) + refs = [e for e in r["edges"] if e["relation"] == "references"] + stamped = {e["wikilink_name"] for e in refs if "wikilink_name" in e} + # beta.md is NOT a sibling -> stamped for post-pass resolution. + assert stamped == {"beta"}, f"expected only 'beta' stamped, got {stamped}" + # alpha-sibling.md IS a sibling -> resolved normally, no stamp needed. + assert any("target_file" in e for e in refs) + + +def test_markdown_path_qualified_link_is_not_stamped(tmp_path): + """[text](./other.md) is relative by spec - sibling resolution stays correct. + + Only wikilinks get the vault-wide fallback; path-qualified markdown links + must keep pure relative semantics. + """ + src_dir = tmp_path / "docs" + src_dir.mkdir() + note = src_dir / "index.md" + note.write_text("# Index\n\n[gone](./nowhere.md)\n", encoding="utf-8") + + r = extract_markdown(note) + refs = [e for e in r["edges"] if e["relation"] == "references"] + assert refs, "expected a references edge for the inline link" + assert not any("wikilink_name" in e for e in refs), \ + "path-qualified links must not get the wikilink fallback stamp" + + +def test_extract_resolves_wikilinks_across_folders(tmp_path): + """Cross-folder [[name]] links resolve against the corpus, not the sibling dir. + + A note-type vault (Concepts/, Entities/, ...) links across folders with bare + names. Sibling-only resolution dropped every one of those edges. + """ + from graphify.extract import extract + + (tmp_path / "Concepts").mkdir() + (tmp_path / "Entities").mkdir() + src = tmp_path / "Concepts" / "alpha.md" + src.write_text("# Alpha\n\nRefers to [[beta]] and [[missing-note]].\n", encoding="utf-8") + tgt = tmp_path / "Entities" / "beta.md" + tgt.write_text("# Beta\n", encoding="utf-8") + + res = extract([src, tgt], cache_root=tmp_path, root=tmp_path, parallel=False) + node_ids = {n["id"] for n in res["nodes"]} + refs = [e for e in res["edges"] if e["relation"] == "references"] + + resolved = [e for e in refs if e["target"] in node_ids] + assert len(resolved) == 1, f"cross-folder [[beta]] should resolve: {refs}" + # A link to a file that does not exist anywhere stays dangling, as before. + assert any(e["target"] not in node_ids for e in refs), \ + "[[missing-note]] must remain dangling" + # The transient stamp never reaches the caller. + assert not any("wikilink_name" in e for e in res["edges"]) + + +def test_extract_wikilink_prefers_same_folder_on_ambiguity(tmp_path): + """When two files share a stem, the linking note's own folder wins.""" + from graphify.extract import extract, _file_node_id + + (tmp_path / "Concepts").mkdir() + (tmp_path / "Entities").mkdir() + src = tmp_path / "Concepts" / "alpha.md" + src.write_text("# Alpha\n\n[[shared]]\n", encoding="utf-8") + near = tmp_path / "Concepts" / "shared.md" + near.write_text("# Near\n", encoding="utf-8") + far = tmp_path / "Entities" / "shared.md" + far.write_text("# Far\n", encoding="utf-8") + + res = extract([src, near, far], cache_root=tmp_path, root=tmp_path, parallel=False) + refs = [e for e in res["edges"] if e["relation"] == "references"] + assert len(refs) == 1 + assert refs[0]["target"] == _file_node_id(Path("Concepts/shared.md")) + + def test_markdown_fenced_heading_not_parsed(): """A '## heading' inside a fenced block must not produce a heading node (#1077).