diff --git a/graphify/extractors/markdown.py b/graphify/extractors/markdown.py index 50c1754a78..739eec9637 100644 --- a/graphify/extractors/markdown.py +++ b/graphify/extractors/markdown.py @@ -14,7 +14,7 @@ _MD_REF_DEF_RE = re.compile(r'^\s{0,3}\[[^\]]+\]:\s*]+)>?') -_MD_WIKILINK_RE = re.compile(r'(? dict: from the resolved target path with the same recipe as the target file's own node, so the edge merges into that node (no ghost node). External URLs, in-page anchors, images and non-document targets are skipped. + Anchored wikilinks follow inline-link semantics: ``[[Page#Heading]]`` + (with or without a ``|alias``) resolves to ``Page``'s node, while a + same-page anchor ``[[#Heading]]`` resolves to that heading's node in the + same file when the heading exists, and to nothing otherwise (#3333). Fenced code blocks (``` ... ```) are skipped during parsing so their contents don't get treated as headings, but no node is emitted for @@ -319,7 +323,20 @@ 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, wikilink: bool = False) -> None: + # Same-page anchors ([[#Heading]], #3333) resolve after the scan, against + # the heading map below: a link may precede the heading it targets, and + # a title with no heading node resolves to nothing rather than dangling. + heading_titles: dict[str, str] = {} + pending_anchors: list[tuple[str, int]] = [] + + def add_link(raw: str, line: int, wikilink: bool = False, + fragment: "str | None" = None) -> None: + if wikilink and not raw.strip(): + # Same-page anchor ([[#Heading]]): resolve after the scan (#3333). + fragment = (fragment or "").strip() + if fragment: + pending_anchors.append((fragment, line)) + return resolved = _resolve_markdown_link(raw, source_dir, wikilink=wikilink) if resolved is None: return @@ -371,7 +388,7 @@ def add_link(raw: str, line: int, wikilink: bool = False) -> 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, wikilink=True) + add_link(m.group(1), line_num, wikilink=True, fragment=m.group(2)) ref_def = _MD_REF_DEF_RE.match(line_text) if ref_def: add_link(ref_def.group(1), line_num) @@ -392,6 +409,9 @@ def add_link(raw: str, line: int, wikilink: bool = False) -> None: # Avoid duplicate heading IDs by appending line number if h_nid in seen_ids: h_nid = _make_id(stem, title, str(line_num)) + # First occurrence wins, so a same-page anchor to a duplicated + # title resolves deterministically (#3333). + heading_titles.setdefault(title, h_nid) add_node(h_nid, title, line_num) # Pop headings at same or deeper level @@ -405,4 +425,14 @@ def add_link(raw: str, line: int, wikilink: bool = False) -> None: heading_stack.append((level, h_nid)) continue + # Resolve deferred same-page anchors against the completed heading map + # (#3333): first occurrence wins, a missing title stays dropped, and the + # add_link self-reference/dedup guards apply to heading targets too. + for title, line in pending_anchors: + target = heading_titles.get(title) + if target is None or target == file_nid or target in linked_targets: + continue + linked_targets.add(target) + add_edge(file_nid, target, "references", line) + return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0} diff --git a/tests/test_languages.py b/tests/test_languages.py index 46dae524c2..cd2495ae1d 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3015,6 +3015,106 @@ def test_markdown_wikilink_fallback_unicode_normalization(tmp_path): for e in refs), f"NFD wikilink missed the NFC file: {refs}" +# ── Anchored wikilinks (#3333) ─────────────────────────────────────────────── + + +def test_markdown_wikilink_regex_captures_page_and_fragment(): + """The wikilink regex matches same-page anchors and captures page + heading.""" + from graphify.extractors.markdown import _MD_WIKILINK_RE + m = _MD_WIKILINK_RE.search("[[Other Page#SomeHeading|alias]]") + assert m, "anchored cross-page wikilink did not match" + assert m.group(1) == "Other Page" + assert m.group(2) == "SomeHeading" + m = _MD_WIKILINK_RE.search("[[#SomeHeading]]") + assert m, "same-page anchor [[#Heading]] did not match" + assert m.group(1) == "" + assert m.group(2) == "SomeHeading" + + +def test_markdown_wikilink_same_page_anchor_resolves(): + """[[#Heading]] resolves to that heading's node in the same file (#3333).""" + r = _md_extract("# Note\n\n## Setup\n\nSteps here.\n\nSee [[#Setup]].\n") + file_id = next(n["id"] for n in r["nodes"] if n.get("node_kind") == "page") + refs = [e for e in r["edges"] if e["relation"] == "references"] + assert refs, "same-page anchor produced no reference edge" + heading_id = next(n["id"] for n in r["nodes"] + if n.get("node_kind") == "heading" and n["label"] == "Setup") + assert any(e["source"] == file_id and e["target"] == heading_id + for e in refs), f"anchor did not resolve to the heading: {refs}" + + +def test_markdown_wikilink_same_page_anchor_forward_reference(): + """[[#Heading]] placed before the heading still resolves — anchors are + resolved after the scan, so a link may precede its target (#3333).""" + r = _md_extract("# Note\n\nSee [[#Setup]] below.\n\n## Setup\n\nSteps.\n") + file_id = next(n["id"] for n in r["nodes"] if n.get("node_kind") == "page") + refs = [e for e in r["edges"] if e["relation"] == "references"] + heading_id = next(n["id"] for n in r["nodes"] + if n.get("node_kind") == "heading" and n["label"] == "Setup") + assert any(e["source"] == file_id and e["target"] == heading_id + for e in refs), f"forward anchor did not resolve: {refs}" + + +def test_markdown_wikilink_anchored_cross_page(tmp_path): + """[[Page#Heading|alias]] resolves to the page's node — the same edge as + [[Page]] (anchor stripped before resolution, mirroring inline links) and + deduped with it (#3333).""" + from graphify.extractors.base import _make_id + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "Other Page.md").write_text("# Other Page\n\n## SomeHeading\n\nBody.\n") + src = pkg / "index.md" + src.write_text( + "# Index\n\nSee [[Other Page#SomeHeading|alias]] and [[Other Page]].\n") + r = extract_markdown(src) + refs = [e for e in r["edges"] if e["relation"] == "references"] + page_nid = _make_id(str(pkg / "Other Page.md")) + assert refs, "anchored cross-page link produced no reference edge" + assert len(refs) == 1, f"anchored and plain wikilinks were not deduped: {refs}" + targets = {e["target"] for e in refs} + assert targets == {page_nid}, ( + f"expected exactly the page node, no heading-granular or ghost target: {refs}") + + +def test_markdown_wikilink_same_page_anchor_missing_heading(): + """[[#Missing]] with no such heading produces no edge — never a dangling + endpoint (#3333, R2).""" + r = _md_extract("# Note\n\n## Setup\n\nSee [[#Missing]].\n") + refs = [e for e in r["edges"] if e["relation"] == "references"] + assert refs == [], f"anchor to a missing heading produced edges: {refs}" + + +def test_markdown_wikilink_degenerate_forms(): + """[[]], [[#]] and [[|alias]] produce no edges and no exception (#3333, R5).""" + r = _md_extract("# Note\n\n## Setup\n\nA [[#]] B [[]] C [[|alias]] D.\n") + refs = [e for e in r["edges"] if e["relation"] == "references"] + assert refs == [], f"degenerate wikilinks produced edges: {refs}" + + +def test_markdown_wikilink_duplicate_heading_resolves_first(): + """A same-page anchor to a duplicated heading resolves to the first + occurrence's node (the un-disambiguated id), deterministically (#3333, R6).""" + r = _md_extract("# Note\n\n## Dup\n\nfirst\n\n## Dup\n\nsecond\n\nLink: [[#Dup]].\n") + refs = [e for e in r["edges"] if e["relation"] == "references"] + assert len(refs) == 1, f"expected one reference edge, got {refs}" + dups = sorted( + (n for n in r["nodes"] + if n.get("node_kind") == "heading" and n["label"] == "Dup"), + key=lambda n: n["source_location"], + ) + assert refs[0]["target"] == dups[0]["id"], ( + f"anchor must resolve to the first occurrence: {refs}") + + +def test_markdown_wikilink_embeds_and_fenced_skipped(): + """Embeds ![[...]] stay unmatched and fenced code is skipped (#3333, R4).""" + r = _md_extract( + "# Note\n\n![[logo.png]] embed stays unmatched.\n\n" + "```\n[[inside-fence]] is not a link.\n```\n") + refs = [e for e in r["edges"] if e["relation"] == "references"] + assert refs == [], f"embeds/fenced wikilinks produced edges: {refs}" + + # ── Groovy ───────────────────────────────────────────────────────────────────