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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user