Resolve cross-file Java type references by import package

Same-named types in different packages left implements/inherits edges stuck on
bare shadow stubs, isolating the real interface (_rewire_unique_stub_nodes only
fixes the globally-unique case). New _resolve_java_type_references pass uses each
referencing file's import statements (+ package decl) to build an FQN->def index,
re-points dangling implements/inherits/imports edges to the exact definition, and
drops the orphaned stub. External/stdlib imports stay unresolved (correct). Runs
after id-disambiguation so target ids are final. Java-scoped; other _extract_generic
languages share the same bare-name fallback and remain a follow-up.

Adds tests/test_java_type_resolution.py (simple, ambiguous-by-import, build-survival).
Refs #1318.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Safi
2026-06-16 03:16:08 +01:00
co-authored by Claude Opus 4.8
parent 4fbb33301c
commit 2100b3d05b
3 changed files with 236 additions and 0 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: cross-file Java `implements`/`inherits`/`imports` edges no longer orphan onto bare "shadow" nodes when two packages define a same-named type. The referencing file's `import` statement now disambiguates by exact package (FQN) and re-points the edge to the real definition, dropping the orphan stub. Previously `_rewire_unique_stub_nodes` could only repair the globally-unique case, so same-named interfaces (common in large Java codebases — `Handler`, `Service`, interface+impl pairs) left the real definition isolated in its own community (#1318).
- Fix: Swift imports of the same module from multiple files now collapse to a single shared `type=module` node instead of N path-qualified duplicates. The import target is tagged `type=module` and exempted from id-disambiguation, so reverse traversal ("what imports CoreKit?") works; the `--no-cluster` writer also now dedupes nodes by id (and edges) to match the clustered `build_from_json` path. Builds on the v0.8.40 Swift-import fix (#1327, #1330; thanks @duncan-daydream).
## 0.8.40 (2026-06-16)
+135
View File
@@ -8641,6 +8641,134 @@ def _resolve_cross_file_java_imports(
return new_edges
def _resolve_java_type_references(
per_file: list[dict],
paths: list[Path],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Re-point dangling Java ``implements``/``inherits`` edges to the real
definition, using the referencing file's ``import`` statements (+ package)
for exact disambiguation.
Cross-file type references resolve by bare name and fall back to a no-source
"shadow" stub. ``_rewire_unique_stub_nodes`` repairs that only when the name
is globally unique; when two packages define a same-named type it bails, so
the ``implements`` edge stays stuck on the shadow node and the real interface
is wrongly isolated (#1318). An ``import com.a.handler.AIResponseHandler``
names the exact package, so it disambiguates where bare-name matching cannot.
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.
"""
try:
import tree_sitter_java as tsjava
from tree_sitter import Language, Parser
except ImportError:
return
language = Language(tsjava.language())
parser = Parser(language)
# package + simple-name->FQN imports, keyed by the source_file string the
# file's own nodes use (so it matches edge/node source_file exactly).
pkg_by_file: dict[str, str] = {}
imports_by_file: dict[str, dict[str, str]] = {}
for path, result in zip(paths, per_file):
srcs = {n.get("source_file") for n in result.get("nodes", []) if n.get("source_file")}
if not srcs:
continue
try:
source = path.read_bytes()
tree = parser.parse(source)
except Exception:
continue
pkg = ""
imps: dict[str, str] = {}
def walk(n) -> None:
nonlocal pkg
if n.type == "package_declaration":
pkg = _read_text(n, source).strip()[len("package"):].strip().rstrip(";").strip()
elif n.type == "import_declaration":
body = _read_text(n, source).strip()[len("import"):].strip().rstrip(";").strip()
if body.startswith("static "):
body = body[len("static "):].strip()
if body.endswith(".*") or "." not in body:
return
simple = body.split(".")[-1]
if simple and simple[0].isupper():
imps[simple] = body
for child in n.children:
walk(child)
walk(tree.root_node)
for s in srcs:
pkg_by_file[s] = pkg
imports_by_file[s] = imps
# FQN (package.Class) -> definition node id, for type-like defs with a source.
fqn_to_id: dict[str, str] = {}
for node in all_nodes:
label = node.get("label", "")
src = node.get("source_file", "")
nid = node.get("id", "")
if not (label and src and nid) or src not in pkg_by_file:
continue
if not label[:1].isupper() or label.endswith(")") or label.endswith(".java"):
continue
pkg = pkg_by_file[src]
fqn_to_id.setdefault(f"{pkg}.{label}" if pkg else label, nid)
# Bare shadow stubs: no source_file, type-like label.
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
# `imports` is included so the file-level import edge that also lands on the
# shadow stub gets re-pointed too, leaving the stub unreferenced (and dropped).
# External/stdlib imports never resolve (no internal def / same-package match),
# so their edges correctly stay on their stub.
REPOINT_RELATIONS = {"implements", "inherits", "extends", "imports"}
repointed_from: set[str] = set()
for edge in all_edges:
if edge.get("relation") not in REPOINT_RELATIONS:
continue
tgt = edge.get("target")
label = stub_label.get(tgt)
if not label:
continue
ref_file = edge.get("source_file", "")
resolved = None
fqn = imports_by_file.get(ref_file, {}).get(label)
if fqn:
resolved = fqn_to_id.get(fqn)
if resolved is None: # same-package reference (no explicit import)
pkg = pkg_by_file.get(ref_file, "")
resolved = fqn_to_id.get(f"{pkg}.{label}" if pkg else label)
if resolved and resolved != tgt:
edge["target"] = resolved
repointed_from.add(tgt)
if not repointed_from:
return
# Drop shadow stubs that 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 extract_objc(path: Path) -> dict:
"""Extract interfaces, implementations, protocols, methods, and imports from .m/.mm/.h files."""
try:
@@ -12034,6 +12162,13 @@ def extract(
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Java cross-file import resolution failed, skipping: %s", exc)
# Re-point dangling implements/inherits edges that bare-name resolution
# left on shadow stubs, using imports for exact-package disambiguation (#1318).
try:
_resolve_java_type_references(java_results, java_paths, all_nodes, all_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Java type-reference resolution failed, skipping: %s", exc)
# Cross-file call resolution for all languages
# Each extractor saved unresolved calls in raw_calls. Now that we have all
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from pathlib import Path
from graphify.build import build_from_json
from graphify.extract import extract
def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def _node_by_id(result: dict, nid: str) -> dict | None:
return next((n for n in result["nodes"] if n.get("id") == nid), None)
def test_java_cross_file_implements_resolves_to_real_def(tmp_path: Path):
# #1318: a cross-file `implements` must land on the real interface def, not a
# bare no-source shadow stub.
iface = _write(
tmp_path / "src/com/x/handler/AIResponseHandler.java",
"package com.x.handler;\npublic interface AIResponseHandler {}\n",
)
impl = _write(
tmp_path / "src/com/x/service/DifyAiServiceImpl.java",
"package com.x.service;\n"
"import com.x.handler.AIResponseHandler;\n"
"public class DifyAiServiceImpl implements AIResponseHandler {}\n",
)
result = extract([iface, impl], cache_root=tmp_path)
implements = [e for e in result["edges"] if e["relation"] == "implements"]
assert implements, "expected an implements edge"
for e in implements:
tgt = _node_by_id(result, e["target"])
assert tgt is not None, f"implements target {e['target']} is not a node"
# The target must be the real definition (has a source_file), not a shadow stub.
assert tgt.get("source_file"), f"implements landed on shadow stub {e['target']}"
assert "handler" in tgt["source_file"]
def test_java_ambiguous_implements_disambiguated_by_import(tmp_path: Path):
# #1318 core case: two interfaces with the SAME simple name in different
# packages. The importing file's `import` must pick the right one, and no
# orphan shadow node may remain.
a = _write(
tmp_path / "src/com/a/handler/AIResponseHandler.java",
"package com.a.handler;\npublic interface AIResponseHandler {}\n",
)
b = _write(
tmp_path / "src/com/b/handler/AIResponseHandler.java",
"package com.b.handler;\npublic interface AIResponseHandler {}\n",
)
impl = _write(
tmp_path / "src/com/x/service/Impl.java",
"package com.x.service;\n"
"import com.a.handler.AIResponseHandler;\n"
"public class Impl implements AIResponseHandler {}\n",
)
result = extract([a, b, impl], cache_root=tmp_path)
# No bare shadow stub for the interface should survive.
shadow = [
n for n in result["nodes"]
if n.get("label") == "AIResponseHandler" and not n.get("source_file")
]
assert not shadow, f"orphan shadow node(s) remain: {[n['id'] for n in shadow]}"
implements = [e for e in result["edges"] if e["relation"] == "implements"]
assert len(implements) == 1
tgt = _node_by_id(result, implements[0]["target"])
assert tgt is not None and tgt.get("source_file")
# Must resolve to the imported package (com/a), not com/b.
assert "com/a/handler" in tgt["source_file"]
assert "com/b/handler" not in tgt["source_file"]
def test_java_implements_edge_survives_build(tmp_path: Path):
# #1318: the re-pointed edge must connect real nodes after graph assembly,
# so the interface is not classified as an isolated community.
iface = _write(
tmp_path / "src/com/x/handler/Handler.java",
"package com.x.handler;\npublic interface Handler {}\n",
)
impl = _write(
tmp_path / "src/com/x/service/Svc.java",
"package com.x.service;\n"
"import com.x.handler.Handler;\n"
"public class Svc implements Handler {}\n",
)
result = extract([iface, impl], cache_root=tmp_path)
G = build_from_json(result, directed=True)
impl_edges = [
(u, v) for u, v, d in G.edges(data=True) if d.get("relation") == "implements"
]
assert impl_edges
# The interface node has an incoming implements edge (not isolated).
assert any(G.in_degree(v) >= 1 for _, v in impl_edges)