Files
graphify/graphify/markdown_resolution.py
T
Ryan Peterson 2d833a0a3c feat(markdown): resolve code-span mentions into references edges
The markdown extractor's docstring promised a heading --references--> node edge
when a backtick Name matches a known pattern, but nothing implemented it, so an
AST build produced no document-to-code edge at all.

extract_markdown now collects inline code spans outside fences and frontmatter as
raw_calls tagged language "markdown", owned by the heading they sit under (the
page before the first heading). A new markdown_mentions language resolver runs
after the id-remap passes and turns them into references edges: the explicit
path::Symbol[::member] form resolves through the cited file's symbols and is
EXTRACTED 1.0; a bare or dotted name resolves only when exactly one callable code
node carries that label and is INFERRED 0.95. Ambiguous names, builtins, missing
files and non-callable matches emit nothing. The shared cross-file call pass
skips markdown raw_calls so prose never emits calls edges.

Tests cover span classification, raw_call ownership, end-to-end resolution,
ambiguity, and cache round-tripping.
2026-09-14 15:12:17 -04:00

176 lines
7.1 KiB
Python

"""Cross-file resolution of code-span mentions in Markdown documents.
``extract_markdown`` reports every inline code span that reads as a symbol
name (``Widget``, ``render()``, ``pkg.Widget``, ``src/mod.py::Widget::render``)
as a ``raw_calls`` entry tagged ``language: "markdown"`` instead of guessing a
target id: the symbol is defined in some other file, and only once every file
is extracted and the id-remap passes have run can the mention be matched to
the node that actually exists. Riding the ``raw_calls`` channel means those
passes rewrite the mention's ``caller_nid`` alongside every other raw call,
and the extraction cache round-trips it for free.
This resolver runs from the language-resolver registry (see
``resolver_registry``) with the merged corpus in hand and turns each mention
into a ``references`` edge from the heading (or page) that cites the symbol
to the code node that defines it:
* a path-qualified mention resolves against the cited file only. The file is
matched by its path relative to the document, then as a segment-aligned
suffix of a scanned file, and must be unique; the symbol chain is walked
through ``contains`` / ``method`` edges so ``mod.py::Widget::render`` is
the ``render`` owned by ``Widget``. The document names the file and the
symbol verbatim, so the edge is EXTRACTED;
* a bare mention resolves only when exactly one callable or type in the
corpus carries that label (case-sensitive; language built-ins excluded).
Only nodes the extractors marked ``_callable`` qualify, for the reason the
indirect-call pass gives (#1566): a by-name match must land on a real
function, method or class, never on a same-named data symbol such as a
JSON key. No tie-breaking: an ambiguous name is a guess, and a guess is
not an edge. The match is INFERRED (0.95, a named cross-file reference).
The shared cross-file call pass in ``extract`` skips ``markdown`` raw calls,
so a mention never surfaces as a ``calls`` edge.
"""
from __future__ import annotations
import os
from typing import Any
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS
MARKDOWN_MENTION_SUFFIXES = frozenset({".md", ".mdx", ".qmd", ".skill"})
_CONTAINMENT_RELATIONS = frozenset({"contains", "method"})
def _symbol_label(node: dict[str, Any]) -> str:
"""A node's label as a document would write it: no ``()``, no leading dot."""
return str(node.get("label", "")).strip("()").lstrip(".")
def _is_file_node(node: dict[str, Any]) -> bool:
label = str(node.get("label", ""))
source_file = str(node.get("source_file", ""))
return bool(source_file) and label == os.path.basename(source_file)
def _posix(path: str) -> str:
return path.replace("\\", "/")
def _match_cited_file(cited: str, doc_file: str, source_files: set[str]) -> str | None:
"""The scanned source file a ``path::Name`` mention names, or None.
Tries the path relative to the citing document first (``../src/mod.py``
from ``docs/guide.md``), then an exact match, then a unique
segment-aligned suffix (``src/mod.py`` naming ``/repo/src/mod.py``).
"""
cited_posix = _posix(cited)
doc_dir = os.path.dirname(doc_file)
relative = _posix(os.path.normpath(os.path.join(doc_dir, cited)))
for candidate in (relative, cited_posix):
if candidate in source_files:
return candidate
suffix = "/" + cited_posix.lstrip("./")
matches = [sf for sf in source_files if sf.endswith(suffix)]
return matches[0] if len(matches) == 1 else None
def _markdown_raw_calls(per_file: list[dict]) -> list[dict]:
calls: list[dict] = []
for result in per_file:
if not isinstance(result, dict):
continue
for rc in result.get("raw_calls", []) or []:
if isinstance(rc, dict) and rc.get("language") == "markdown":
calls.append(rc)
return calls
def resolve_markdown_mentions(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Turn ``language: "markdown"`` raw calls into ``references`` edges in place."""
mentions = _markdown_raw_calls(per_file)
if not mentions:
return
code_nodes = [
n for n in all_nodes
if n.get("id") and n.get("file_type") == "code" and not _is_file_node(n)
]
by_label: dict[str, list[str]] = {}
by_file: dict[str, dict[str, list[str]]] = {}
for n in code_nodes:
label = _symbol_label(n)
if not label:
continue
if n.get("_callable"):
by_label.setdefault(label, []).append(n["id"])
source_file = _posix(str(n.get("source_file", "")))
if source_file:
by_file.setdefault(source_file, {}).setdefault(label, []).append(n["id"])
source_files = set(by_file)
parents: dict[str, set[str]] = {}
for e in all_edges:
if e.get("relation") in _CONTAINMENT_RELATIONS:
parents.setdefault(str(e.get("target")), set()).add(str(e.get("source")))
node_ids = {n["id"] for n in all_nodes if n.get("id")}
existing = {
(e.get("source"), e.get("target"))
for e in all_edges if e.get("relation") == "references"
}
def _resolve_chain(file_labels: dict[str, list[str]], names: list[str]) -> str | None:
candidates = list(file_labels.get(names[-1], []))
for depth, owner_name in enumerate(reversed(names[:-1]), start=1):
owners = set(file_labels.get(owner_name, []))
narrowed = []
for c in candidates:
chain = {c}
for _ in range(depth):
chain = {p for nid in chain for p in parents.get(nid, ())}
if chain & owners:
narrowed.append(c)
candidates = narrowed
return candidates[0] if len(candidates) == 1 else None
for rc in mentions:
caller = str(rc.get("caller_nid", ""))
callee = str(rc.get("callee", "")).strip()
if not caller or not callee or caller not in node_ids:
continue
names = list(rc.get("qualifiers") or []) + [callee]
cited = rc.get("path")
if cited:
source_file = _match_cited_file(
str(cited), _posix(str(rc.get("source_file", ""))), source_files
)
if source_file is None:
continue
target = _resolve_chain(by_file[source_file], names)
confidence, score = "EXTRACTED", 1.0
else:
if callee in _LANGUAGE_BUILTIN_GLOBALS:
continue
candidates = by_label.get(callee, [])
target = candidates[0] if len(candidates) == 1 else None
confidence, score = "INFERRED", 0.95
if target is None or target == caller or (caller, target) in existing:
continue
existing.add((caller, target))
all_edges.append({
"source": caller,
"target": target,
"relation": "references",
"context": rc.get("context", ""),
"confidence": confidence,
"confidence_score": score,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})