mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-25 16:22:30 +00:00
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:
1 parent
b90b9df794
commit
6bba91fc65
2 files changed
+131
-20
No files matched your search
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user