diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1e6c4..3821093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/extract.py b/graphify/extract.py index 104b627..fa9b21e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 diff --git a/tests/test_java_type_resolution.py b/tests/test_java_type_resolution.py new file mode 100644 index 0000000..40018c5 --- /dev/null +++ b/tests/test_java_type_resolution.py @@ -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)