fix(markdown): a bare method name needs its class cited (#4609)

`resolve_markdown_mentions` linked any bare, parenthesis-less code span to
the corpus's single `_callable` carrying that label, and methods qualify.
A prose word in a code span (`redis` the server, `data`, `message`) then
became `heading --references--> SomeClass.word()` at INFERRED 0.95, on a
method that has nothing to do with the sentence. On the reporter's corpus
44 of 1,906 mention edges were words of this kind.

A bare single-name span is now resolved in three passes. Explicit evidence
(a cited path, a dotted qualifier chain, or a trailing `()`) resolves as
before. Bare mentions that do not name a method keep resolving, and their
targets are recorded per document. A bare mention that names a method is
kept only when its owner class is already cited in the same document, so
`redis` no longer links to `Cache.redis` while `successWithWarnings` next
to `HandlerResult` still does. Ambiguity, built-ins and the case-sensitive
label match are unchanged.

Tests cover the issue repro, the owner-cited case, the `()` case, a free
function, and the cross-document guard.
This commit is contained in:
nothariharan committed 2026-10-21 13:21:48 +05:30
1 parent b90b9df794
commit 6bba91fc65
2 files changed
+131 -20

No files matched your search

+72 -20
View File
@@ -31,6 +31,11 @@ to the code node that defines it:
source path: ``time.sleep`` never lands on a repo's own ``sleep``, and
``Widget.render`` picks the ``render`` that ``Widget`` owns. No other
tie-breaking: an ambiguous name is a guess, and a guess is not an edge.
A bare, parenthesis-less method name is held to one more test: a word like
``redis`` is prose as often as it is a reference, so a method match survives
only when the citing document also names the class that owns it (#4609).
The trailing ``()``, a dotted form, or a class/function label carries enough
evidence on its own.
The match is INFERRED (0.95, a named cross-file reference).
An explicit relative cite (``./`` or ``../``) resolves against the document's
@@ -190,33 +195,42 @@ def resolve_markdown_mentions(
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]
def _target(rc: dict) -> tuple[str | None, str, float]:
"""The node a mention resolves to, plus the confidence of that match."""
names = list(rc.get("qualifiers") or []) + [str(rc.get("callee", "")).strip()]
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
qualifiers = set(names[:-1])
candidates = [
c for c in by_label.get(callee, [])
if qualifiers <= _evidence(c, nodes_by_id, parents)
]
target = candidates[0] if len(candidates) == 1 else None
confidence, score = "INFERRED", 0.95
return None, "EXTRACTED", 1.0
return _resolve_chain(by_file[source_file], names), "EXTRACTED", 1.0
callee = names[-1]
if callee in _LANGUAGE_BUILTIN_GLOBALS:
return None, "INFERRED", 0.95
qualifiers = set(names[:-1])
candidates = [
c for c in by_label.get(callee, [])
if qualifiers <= _evidence(c, nodes_by_id, parents)
]
return (candidates[0] if len(candidates) == 1 else None), "INFERRED", 0.95
def _method_owner(node_id: str) -> str | None:
"""The class that owns *node_id*, when it is a method and not a function."""
for parent in parents.get(node_id, ()):
node = nodes_by_id.get(parent)
if node is not None and node.get("_callable_class"):
return parent
return None
#: Targets already cited by each document, used as evidence in pass 2b.
cited_targets: dict[str, set[str]] = {}
def _add(rc: dict, target: str | None, confidence: str, score: float) -> None:
caller = str(rc.get("caller_nid", ""))
if target is None or target == caller or (caller, target) in existing:
continue
return
existing.add((caller, target))
all_edges.append({
"source": caller,
@@ -229,3 +243,41 @@ def resolve_markdown_mentions(
"source_location": rc.get("source_location"),
"weight": 1.0,
})
cited_targets.setdefault(_posix(str(rc.get("source_file", ""))), set()).add(target)
# Pass 1: explicit evidence resolves on its own — a cited path, a dotted
# qualifier chain, or a trailing (). Record those targets per document.
# Pass 2 handles the bare, parenthesis-less, single-name spans, where the
# word alone (`redis`, `data`, `message`) is indistinguishable from prose.
bare: list[dict] = []
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]
span = str(rc.get("context") or "")
if rc.get("path") or len(names) > 1 or "(" in span:
_add(rc, *_target(rc))
else:
bare.append(rc)
# Pass 2a: bare mentions that do not name a method. A class or free function
# label is specific enough to keep matching, and the class a later method
# mention needs must be on record before pass 2b runs.
method_mentions: list[tuple[dict, str, str, float]] = []
for rc in bare:
target, confidence, score = _target(rc)
if target is not None and _method_owner(target) is not None:
method_mentions.append((rc, target, confidence, score))
else:
_add(rc, target, confidence, score)
# Pass 2b: a bare method name (#4609) is only evidence when the same
# document already cites the class that owns it. `redis` the server does not
# link to `Cache.redis` unless the document names `Cache`;
# `successWithWarnings` survives because `HandlerResult` is cited next to it.
for rc, target, confidence, score in method_mentions:
doc = _posix(str(rc.get("source_file", "")))
if _method_owner(target) in cited_targets.get(doc, ()):
_add(rc, target, confidence, score)
+59
View File
@@ -287,3 +287,62 @@ def test_mentions_survive_the_extraction_cache(tmp_path):
guide = _node(second, "Guide")
widget = _node(second, "Widget", "widget.py")
assert (guide["id"], widget["id"]) in _pairs(second)
def test_bare_lowercase_word_does_not_link_to_an_unrelated_method(tmp_path):
"""`redis` the server is prose, not a reference to whichever method is named redis (#4609)."""
r = _extract(tmp_path, {
"cache.py": "class Cache:\n def redis(self):\n return 'connection'\n",
"README.md": "# Queue\n\nJobs run on `redis` (the server, not our code).\n",
})
queue = _node(r, "Queue")
assert {t for (s, t) in _references(r) if s == queue["id"]} == set()
def test_bare_method_mention_survives_when_the_document_cites_its_class(tmp_path):
"""`successWithWarnings` next to `HandlerResult` is a real reference (#4609)."""
r = _extract(tmp_path, {
"handler.py": "class HandlerResult:\n def successWithWarnings(self):\n return 1\n",
"notes.md": "# Notes\n\n`HandlerResult` returns `successWithWarnings`.\n",
})
notes = _node(r, "Notes")
method = _node(r, ".successWithWarnings()")
assert (notes["id"], method["id"]) in _references(r)
def test_bare_method_with_parentheses_still_resolves(tmp_path):
"""A trailing () is explicit evidence, so the owner does not have to be cited (#4609)."""
r = _extract(tmp_path, {
"cache.py": "class Cache:\n def redis(self):\n return 'connection'\n",
"notes.md": "# Notes\n\nCall `redis()` on the cache.\n",
})
notes = _node(r, "Notes")
method = _node(r, ".redis()")
assert (notes["id"], method["id"]) in _references(r)
def test_bare_function_mention_still_resolves_without_its_owner(tmp_path):
"""The extra owner test is scoped to methods; a free function label stays evidence (#4609)."""
r = _extract(tmp_path, {
"deploy.py": "def deploy():\n pass\n",
"notes.md": "# Notes\n\nRun `deploy` after the tests.\n",
})
notes = _node(r, "Notes")
deploy = _node(r, "deploy()", "deploy.py")
assert (notes["id"], deploy["id"]) in _references(r)
def test_owner_cited_in_another_document_does_not_count(tmp_path):
"""The owner evidence is per document: one doc's mention cannot license another's (#4609)."""
r = _extract(tmp_path, {
"handler.py": "class HandlerResult:\n def successWithWarnings(self):\n return 1\n",
"a.md": "# A\n\n`HandlerResult` is the result type.\n",
"b.md": "# B\n\nReads `successWithWarnings`.\n",
})
b = _node(r, "B")
assert {t for (s, t) in _references(r) if s == b["id"]} == set()