mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 08:29:26 +00:00
fix(markdown): honour dotted qualifiers and explicit relative cites in mention resolution
Three findings from the automated review of #3562, all reproduced:
A dotted span with a non-code extension (README.md, pyproject.toml) was classified as a
symbol because only CODE_EXTENSIONS was checked, and every dotted form was reduced to its
last segment, so time.sleep matched a repo's own sleep(). The dotted form now keeps its
whole chain, the file early-exit also checks DOC_EXTENSIONS, and a bare-name match must
carry every qualifier as an owner label or a segment or stem of its source path.
_match_cited_file used lstrip("./"), which strips characters, so a .github/... cite could
never match by suffix. The leading ./ and ../ segments are now stripped as segments, and
the suffix form also matches a root-level file.
An explicit ./ or ../ cite that escaped the corpus fell through to the suffix rule and
could land on an unrelated copy of the file. It now resolves relative to the document
only.
(cherry picked from commit 3f84e7f3e2)
This commit is contained in:
1 parent
7dbc668933
commit
8cfebfdfd2
3 files changed
+143
-26
No files matched your search
@@ -8,11 +8,13 @@ resolver matches them against the merged corpus after the id-remap passes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extract import extract
|
||||
from graphify.extractors.markdown import _code_span_mention, extract_markdown
|
||||
from graphify.markdown_resolution import _match_cited_file
|
||||
|
||||
_WIDGET_PY = '''\
|
||||
class Widget:
|
||||
@@ -81,15 +83,42 @@ def _references(r):
|
||||
def test_code_span_mentions_are_classified():
|
||||
assert _code_span_mention("Widget") == (None, ["Widget"])
|
||||
assert _code_span_mention("render()") == (None, ["render"])
|
||||
assert _code_span_mention("pkg.sub.Widget") == (None, ["Widget"])
|
||||
assert _code_span_mention("pkg.sub.Widget") == (None, ["pkg", "sub", "Widget"])
|
||||
assert _code_span_mention("Widget.render()") == (None, ["Widget", "render"])
|
||||
# A file-like span with an unknown extension classifies as a mention and
|
||||
# is rejected at resolution, where `pyproject` matches no callable.
|
||||
assert _code_span_mention("pyproject.toml") == (None, ["pyproject", "toml"])
|
||||
pinned = ("src/widget.py", ["Widget", "render"])
|
||||
assert _code_span_mention("src/widget.py::Widget::render") == pinned
|
||||
assert _code_span_mention("src/widget.py::Widget::render()") == pinned
|
||||
# Files, commands, expressions and prose are not symbol mentions.
|
||||
for span in ("setup.py", "git revert", "x = 1", "a-b", "--flag", "", "src/widget.py"):
|
||||
for span in ("setup.py", "README.md", "notes.txt", "git revert", "x = 1", "a-b",
|
||||
"--flag", "", "src/widget.py"):
|
||||
assert _code_span_mention(span) is None, span
|
||||
|
||||
|
||||
def test_cited_file_matching():
|
||||
sources = {"src/mod.py", ".github/scripts/check.py", "lib/x.py", "vendor/lib/x.py",
|
||||
"docs/src/mod.py"}
|
||||
# Doc-relative first, then exact, then a unique segment-aligned suffix.
|
||||
assert _match_cited_file("src/mod.py", "docs/guide.md", sources) == "docs/src/mod.py"
|
||||
assert _match_cited_file("src/mod.py", "README.md", sources) == "src/mod.py"
|
||||
assert _match_cited_file("mod.py", "README.md", sources) is None
|
||||
assert _match_cited_file("scripts/check.py", "README.md", sources) == (
|
||||
".github/scripts/check.py")
|
||||
# `./` and `../` are stripped as segments: a hidden directory keeps its dot.
|
||||
assert _match_cited_file(".github/scripts/check.py", "docs/guide.md", sources) == (
|
||||
".github/scripts/check.py")
|
||||
assert _match_cited_file("./src/mod.py", "README.md", sources) == "src/mod.py"
|
||||
# The suffix form also names a root-level file.
|
||||
assert _match_cited_file("lib/x.py", "docs/guide.md", {"lib/x.py"}) == "lib/x.py"
|
||||
# An explicit relative cite resolves against the document only: escaping
|
||||
# the corpus never falls through to another copy of the file.
|
||||
assert _match_cited_file("../lib/x.py", "docs/guide.md", sources) == "lib/x.py"
|
||||
assert _match_cited_file("../lib/x.py", "README.md", sources) is None
|
||||
assert _match_cited_file("../../vendor/lib/x.py", "docs/guide.md", sources) is None
|
||||
|
||||
|
||||
def test_extract_markdown_reports_mentions_as_markdown_raw_calls(tmp_path):
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir()
|
||||
@@ -152,9 +181,11 @@ def test_mentions_resolve_to_references_edges_end_to_end(tmp_path):
|
||||
assert refs[(guide["id"], widget["id"])]["confidence"] == "INFERRED"
|
||||
assert refs[(guide["id"], widget["id"])]["confidence_score"] == 0.95
|
||||
assert (rendering["id"], widget["id"]) in refs
|
||||
# Dotted `Widget.render()` resolves by its last segment: `render` is
|
||||
# ambiguous (two classes define it), so no edge; same for `helper()`.
|
||||
assert {t for (s, t) in refs if s == rendering["id"]} == {widget["id"]}
|
||||
# `render` is defined twice, but the qualifier in `Widget.render()` names
|
||||
# the owner, so the mention resolves; the bare `helper()` stays ambiguous.
|
||||
assert {t for (s, t) in refs if s == rendering["id"]} == {
|
||||
widget["id"], widget_render["id"]}
|
||||
assert refs[(rendering["id"], widget_render["id"])]["confidence"] == "INFERRED"
|
||||
# Path-qualified mentions are EXTRACTED and scoped to the cited file.
|
||||
assert refs[(pinned["id"], widget_render["id"])]["confidence"] == "EXTRACTED"
|
||||
assert refs[(pinned["id"], widget_render["id"])]["confidence_score"] == 1.0
|
||||
@@ -183,6 +214,27 @@ def test_ambiguous_bare_name_yields_no_edge(tmp_path):
|
||||
assert {t for (s, t) in _references(r) if s == notes["id"]} == set()
|
||||
|
||||
|
||||
def test_dotted_mentions_need_qualifier_evidence(tmp_path):
|
||||
r = _extract(tmp_path, {
|
||||
"src/widget.py": _WIDGET_PY + "\n\ndef sleep():\n pass\n",
|
||||
"src/gadget.py": _GADGET_PY + "\n\ndef toml():\n pass\n",
|
||||
"docs/notes.md": (
|
||||
"# Notes\n\n"
|
||||
"`time.sleep` and `pyproject.toml` are not this repo's sleep or toml.\n"
|
||||
"`widget.Widget`, `src.gadget.Gadget` and `Widget.render` are.\n"
|
||||
"`Gadget.helper` is not: `helper` is not owned by `Gadget`.\n"
|
||||
),
|
||||
})
|
||||
|
||||
notes = _node(r, "Notes")
|
||||
cited = {t for (s, t) in _references(r) if s == notes["id"]}
|
||||
widget = _node(r, "Widget", "widget.py")
|
||||
gadget = _node(r, "Gadget", "gadget.py")
|
||||
widget_render = next(
|
||||
n for n in r["nodes"] if n["label"] == ".render()" and "widget" in n["id"])
|
||||
assert cited == {widget["id"], gadget["id"], widget_render["id"]}
|
||||
|
||||
|
||||
def test_mentions_survive_the_extraction_cache(tmp_path):
|
||||
files = {"src/widget.py": _WIDGET_PY, "docs/guide.md": _GUIDE_MD}
|
||||
first = _extract(tmp_path, files)
|
||||
|
||||
Reference in new issue
Block a user