Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
_resolve_export_target,
_resolve_java_type_references,
_resolve_php_type_references,
_resolve_python_type_references,
_resolve_js_import_path,
_resolve_js_import_target,
_resolve_js_module_path,
Expand Down Expand Up @@ -5118,6 +5119,13 @@ def _learn(e: dict) -> None:
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc)
# Re-point type-reference edges the bare-name rewire left on shadow stubs
# because the label was ambiguous, using the file's own imports (#2363).
try:
_resolve_python_type_references(paths, root, all_nodes, all_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Python type-reference resolution failed, skipping: %s", exc)

# Cross-file Java import resolution
java_paths = [p for p in paths if p.suffix == ".java"]
Expand Down
33 changes: 33 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2510,6 +2510,39 @@ def walk(node, parent_class_nid: str | None = None) -> None:
base_nid = ensure_named_node(base, line)
add_edge(class_nid, base_nid, "inherits", line)

# Class-body field annotations (`point: PricePoint`), which is the
# dataclass shape (#2363). _python_collect_type_refs was only ever
# reached from function parameters and return types, so a field type
# produced no edge at all — while Java (record components / field
# declarations) and TS (`public_field_definition`) both emit
# `references`/context="field" for the identical shape. An assignment
# without a `type` child is a plain value binding (`plain = 3`), not a
# type reference, so it is skipped.
body_node = node.child_by_field_name("body")
if body_node is not None:
for stmt in body_node.children:
if stmt.type != "expression_statement":
continue
for assign in stmt.children:
if assign.type != "assignment":
continue
type_node = assign.child_by_field_name("type")
if type_node is None:
continue
field_refs: list[tuple[str, str]] = []
_python_collect_type_refs(type_node, source, False, field_refs)
field_line = assign.start_point[0] + 1
for ref_name, role in field_refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
target_nid = ensure_named_node(ref_name, field_line)
if target_nid != class_nid:
# Same emitter as the parameter/return-type path
# below, which validates ctx against
# REFERENCE_CONTEXTS rather than trusting it.
edges.append(_semantic_reference_edge(
class_nid, target_nid, ctx, str_path, field_line
))

# Swift-specific: conformance / inheritance
if config.ts_module == "tree_sitter_swift":
swift_kind = _swift_declaration_keyword(node) if t == "class_declaration" else "protocol"
Expand Down
112 changes: 112 additions & 0 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -2252,6 +2252,118 @@ def walk(n) -> None:

return new_edges

def _resolve_python_type_references(
paths: list[Path],
root: Path,
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Re-point dangling Python type-reference edges at the imported definition.

Python type annotations resolve by bare name and fall back to a sourceless
"shadow" stub (``ensure_named_node``). ``_rewire_unique_stub_nodes`` repairs
that only when the label is globally unique; when two modules define the same
class name it bails, so the reference stays stuck on the shadow node and both
real definitions look unreferenced (#2363). A ``from pkg.a.base import
PricePoint`` names the defining module exactly, so it disambiguates where
bare-name matching cannot.

Mirrors ``_resolve_java_type_references`` and the C# resolver, including
their cost: ``_collect_python_symbol_resolution_facts`` re-parses every
Python file, and it already ran once earlier in the pipeline. The shadow-stub
check below skips that when there is nothing to repoint, but a sourceless
stub is NOT rare — every stdlib or third-party annotation
(``sqlite3.Connection``, ``pathlib.Path``) leaves one — so on most real
corpora this pass does run, at roughly 15ms per file. Narrowing the gate to
"a stub label names a known definition" was tried and rejected: it breaks
``from X import Y as Z``, where the stub carries the local alias ``Z`` and no
definition is labeled ``Z``.

Mutates ``all_nodes``/``all_edges`` in place. Runs after id-disambiguation so
target ids are final, and after ``_rewire_unique_stub_nodes`` so it only has
to handle the ambiguous remainder.
"""
py_paths = [path for path in paths if path.suffix == ".py"]
if not py_paths:
return

# Sourceless shadow stubs with a type-like label — the only repoint targets.
stub_label: dict[str, str] = {
node["id"]: node.get("label", "")
for node in all_nodes
if node.get("id")
and not node.get("source_file")
and node.get("label", "")[:1].isupper()
}
if not stub_label:
return

# (defining file, symbol label) -> definition node id.
def_by_file_label: dict[tuple[str, str], str] = {}
for node in all_nodes:
source_file = str(node.get("source_file", ""))
label = str(node.get("label", ""))
nid = node.get("id")
if not (source_file and label and isinstance(nid, str) and nid):
continue
if not _is_type_like_definition(node):
continue
def_by_file_label.setdefault((_source_key(source_file, root), label), nid)

facts = _SymbolResolutionFacts()
_collect_python_symbol_resolution_facts(py_paths, root, facts)
if not facts.imports:
return

# importing file -> local binding -> definition node id. Keyed on the LOCAL
# name so `from pkg.a.base import PricePoint as PP` binds `PP`.
alias_by_file: dict[str, dict[str, str]] = {}
for imp in facts.imports:
if imp.target_path is None or not imp.local_name:
continue
resolved = def_by_file_label.get(
(_source_key(str(imp.target_path), root), imp.imported_name)
)
if resolved:
alias_by_file.setdefault(
_source_key(str(imp.file_path), root), {}
)[imp.local_name] = resolved
if not alias_by_file:
return

# Deliberately NARROWER than the Java/C# repoint sets, which also carry
# `imports`: a Python file-level `imports` edge is resolved to the real
# definition upstream and never lands on a bare-name stub, so including it
# here would be an untested no-op. Verified against ambiguous, aliased, and
# unresolvable-module corpora.
REPOINT_RELATIONS = {"references", "inherits", "implements"}
repointed_from: set[str] = set()
for edge in all_edges:
if edge.get("relation") not in REPOINT_RELATIONS:
continue
target = edge.get("target")
label = stub_label.get(target)
if not label:
continue
ref_file = _source_key(str(edge.get("source_file", "")), root)
resolved = alias_by_file.get(ref_file, {}).get(label)
if resolved and resolved != target:
edge["target"] = resolved
repointed_from.add(str(target))

if not repointed_from:
return

# Drop shadow stubs no edge references anymore.
still_referenced: set[str] = set()
for edge in all_edges:
still_referenced.add(edge.get("source"))
still_referenced.add(edge.get("target"))
all_nodes[:] = [
node for node in all_nodes
if node.get("id") not in repointed_from or node.get("id") in still_referenced
]

def _resolve_java_type_references(
per_file: list[dict],
paths: list[Path],
Expand Down
Loading
Loading