diff --git a/graphify/extract.py b/graphify/extract.py index fd500781..0779fb47 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: diff --git a/graphify/extractors/markdown.py b/graphify/extractors/markdown.py index 8c67d681..50c1754a 100644 --- a/graphify/extractors/markdown.py +++ b/graphify/extractors/markdown.py @@ -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) diff --git a/tests/test_languages.py b/tests/test_languages.py index 870bac59..2c0ba620 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -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 ───────────────────────────────────────────────────────────────────