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).