From e6eaad3d7e30424e6a8b02f38196487bcea7dd0e Mon Sep 17 00:00:00 2001 From: Safi Date: Fri, 19 Jun 2026 10:02:27 +0100 Subject: [PATCH] Emit edges for markdown links so hub docs connect (#1376) extract_markdown only emitted heading nodes + contains edges and never parsed link syntax, so a doc full of [text](./other.md) links (index.md, table-of-contents.md) had no edges to the docs it links and never became a hub. Add a deterministic link pass: inline, reference-style, and [[wikilinks]], resolved relative to the source file, external URLs/anchors/images skipped, with the target id built via the same _make_id recipe so the edge merges onto the real doc node instead of an orphan. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 88 +++++++++++++++++++++++++++++++++++++++++ tests/test_languages.py | 62 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index e6de255b3..0e90566c9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -9668,6 +9668,56 @@ def extract_elixir(path: Path) -> dict: return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls, "input_tokens": 0, "output_tokens": 0} +# Inline markdown link: [text](target "optional title"). The negative lookbehind +# excludes images (![alt](src)). The target stops at whitespace/closing paren so +# an optional "title" after the URL is dropped; an optional <...> wrapper is too. +_MD_INLINE_LINK_RE = re.compile(r'(?]+)>?(?:\s+[^)]*)?\)') +# Reference-style link definition line: [label]: target "optional title" +_MD_REF_DEF_RE = re.compile(r'^\s{0,3}\[[^\]]+\]:\s*]+)>?') +# Obsidian-style wikilink: [[target]] / [[target|alias]] / [[target#anchor]]. +_MD_WIKILINK_RE = re.compile(r'(? "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 + target is a *local* relative/absolute file-path link to a document, or None + when it should be skipped: external URLs (http/https/mailto/protocol- + relative/data), pure in-page anchors (``#section``), and links to non-doc + file types (code/assets are handled by their own extractors). + + 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``. + """ + target = raw.strip() + if not target: + return None + # Drop anchor / query so #section links still resolve to the target doc. + target = target.split("#", 1)[0].split("?", 1)[0].strip() + if not target: + return None + low = target.lower() + if "://" in target or low.startswith(("mailto:", "tel:", "//", "data:")): + return None + suffix = Path(target).suffix.lower() + if suffix == "": + target = target + ".md" + suffix = ".md" + if suffix not in _MD_LINKABLE_EXTS: + return None + candidate = Path(target) + if not candidate.is_absolute(): + candidate = source_dir / candidate + return Path(os.path.normpath(str(candidate))) + + def extract_markdown(path: Path) -> dict: """Extract structural nodes and edges from a Markdown file. @@ -9679,6 +9729,13 @@ def extract_markdown(path: Path) -> dict: - file --contains--> heading - parent heading --contains--> child heading (nesting by level) - heading --references--> other node (when backtick `Name` matches a known pattern) + - file --references--> linked document, for inline ``[text](./other.md)``, + reference-style ``[label]: ./other.md`` and ``[[wikilink]]`` links, so a + hub doc (``index.md`` / ``table-of-contents.md``) becomes a real hub node + instead of an under-connected orphan (#1376). The target node ID is built + 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. Fenced code blocks (``` ... ```) are skipped during parsing so their contents don't get treated as headings, but no node is emitted for @@ -9713,6 +9770,26 @@ def extract_markdown(path: Path) -> dict: file_nid = _make_id(str(path)) add_node(file_nid, path.name, 1) + source_dir = path.parent + # Dedup link edges by resolved target node so a hub doc that links to the + # 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) + if resolved is None: + return + # Build the target ID with the SAME recipe as the target file's own + # node (_make_id(str(path)) at extract time, canonicalized to + # _file_node_id(rel) by the extract() post-pass). Using the absolute + # resolved path means both endpoints get remapped identically, so the + # edge merges into the existing doc node instead of spawning a ghost. + tgt_nid = _make_id(str(resolved)) + if tgt_nid == file_nid or tgt_nid in linked_targets: + return + linked_targets.add(tgt_nid) + add_edge(file_nid, tgt_nid, "references", line) + # Track heading stack for nesting: [(level, nid), ...] heading_stack: list[tuple[int, str]] = [] in_code_block = False @@ -9731,6 +9808,17 @@ def extract_markdown(path: Path) -> dict: if in_code_block: continue + # Markdown links -> document references (#1376). Scanned on every + # non-fenced line (including heading lines, which the heading branch + # below `continue`s past) so links anywhere in the doc are captured. + 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) + ref_def = _MD_REF_DEF_RE.match(line_text) + if ref_def: + add_link(ref_def.group(1), line_num) + # Detect headings: # Heading, ## Heading, etc. heading_match = re.match(r'^(#{1,6})\s+(.+)', line_text) if heading_match: diff --git a/tests/test_languages.py b/tests/test_languages.py index 11b2b5cb1..3901a2f96 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1421,6 +1421,68 @@ def test_markdown_no_dangling_edges(): assert e["source"] in node_ids, f"Dangling source: {e}" +def _md_link_fixture(tmp_path): + """A hub doc linking to sibling docs, plus those docs (#1376).""" + pkg = tmp_path / "packages" / "coding-standards-csharp" + pkg.mkdir(parents=True) + (pkg / "index.md").write_text( + "# C# Coding Standards\n\n" + "| Topic | Doc |\n| --- | --- |\n" + "| Repository | [C# Repository Standards](./repository.md) |\n" + "| HTTP Client | [C# HTTP Client Standards](http-client.md) |\n" + "| Unit Tests | [C# Unit Test Standards](unit-tests.md) |\n\n" + "See also [external](https://example.com/x) and ![logo](./logo.png).\n" + "Anchor: [section](./repository.md#setup).\n" + "Wikilink: [[http-client]].\n" + ) + (pkg / "repository.md").write_text("# C# Repository Standards\nContent.\n") + (pkg / "http-client.md").write_text("# C# HTTP Client Standards\nContent.\n") + (pkg / "unit-tests.md").write_text("# C# Unit Test Standards\nContent.\n") + return pkg + + +def test_markdown_link_edges_emitted(tmp_path): + """Inline/wikilink markdown links to sibling docs become references edges (#1376).""" + pkg = _md_link_fixture(tmp_path) + r = extract_markdown(pkg / "index.md") + refs = [e for e in r["edges"] if e["relation"] == "references"] + targets = {e["target"] for e in refs} + # repository, http-client, unit-tests — each exactly once (deduped despite + # the anchor link and wikilink pointing at repository/http-client again). + assert len(refs) == 3, f"expected 3 reference edges, got {refs}" + assert any("repository" in t for t in targets) + assert any("http_client" in t for t in targets) + assert any("unit_tests" in t for t in targets) + + +def test_markdown_link_skips_external_and_images(tmp_path): + """External URLs, in-page anchors and images must not produce edges (#1376).""" + pkg = _md_link_fixture(tmp_path) + r = extract_markdown(pkg / "index.md") + refs = [e for e in r["edges"] if e["relation"] == "references"] + for e in refs: + assert "example.com" not in e["target"] + assert "logo" not in e["target"] + + +def test_markdown_link_edges_resolve_to_real_nodes(tmp_path): + """End-to-end: after extract()'s ID remap, link targets are real doc nodes, + so the hub doc gains edges into existing nodes instead of ghost nodes (#1376).""" + from graphify.extract import extract + pkg = _md_link_fixture(tmp_path) + paths = sorted(pkg.glob("*.md")) + res = extract(paths, cache_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"] + assert refs, "expected reference edges after full extract" + for e in refs: + assert e["target"] in node_ids, f"link target is a ghost node: {e}" + # index.md must connect to all three sibling docs. + index_id = next(n["id"] for n in res["nodes"] if n["label"] == "index.md") + index_refs = {e["target"] for e in refs if e["source"] == index_id} + assert len(index_refs) == 3, f"hub doc under-connected: {index_refs}" + + # ── Groovy ───────────────────────────────────────────────────────────────────