fix(extract): resolve Java cross-module type references by import (#1744)

Two classes with the same simple name in different Maven modules
(FinancialEntryValidator in payment/ and core/) already survive as distinct
path-scoped nodes on v8 -- the "node silently disappears" report from 0.9.9 is
fixed. But a cross-module field/type `references` edge was still left dangling
on a sourceless phantom stub: _resolve_java_type_references (#1318) re-pointed
implements/inherits/extends/imports edges to the real definition using the
importing file's `import` statement, but its REPOINT_RELATIONS omitted
`references`, so bare-name resolution's shadow stub survived for field types.
A query about the referenced class could then miss it.

Add `references` to the Java resolver's REPOINT_RELATIONS. The C# sibling
already covers references; this brings Java to parity. The reference now
resolves to the imported package's class (falling back to same-package), and
the orphaned phantom is dropped. Regression test covers the ambiguous
two-module case: both reals present, no phantom, reference lands on the
imported class.

Reported with a precise root-cause and repro by @aviciot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-09 10:44:50 +01:00
co-authored by Claude Opus 4.8
parent d2d1f68ff9
commit ee1ff3d691
4 changed files with 64 additions and 3 deletions
+4
View File
@@ -2,6 +2,10 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.9.12 (unreleased)
- Fix: a Java field/parameter/return-type reference to a class whose simple name is shared by two modules no longer dangles on a sourceless phantom node (#1744, thanks @aviciot). Both same-named classes already survive as distinct path-scoped nodes, but the cross-module `references` edge was left pointing at a bare no-source stub because `_resolve_java_type_references` re-pointed `implements`/`inherits`/`imports` but not `references` — so a query about the referenced class could miss it. The Java resolver now disambiguates `references` by the importing file's `import` statement (falling back to same-package), mirroring the C# resolver, and drops the orphaned phantom.
## 0.9.11 (2026-07-08)
- Fix: file enumeration no longer silently drops a directory subtree. `detect()`'s `os.walk` had no `onerror` handler, so an `os.scandir` failure (a permission error, or a directory created/deleted mid-walk by concurrent writes) was swallowed and that whole subtree vanished from the scan with no log, yielding a silently partial `graph.json`. The walk now records every skipped directory (surfaced in the result's `walk_errors`) and warns to stderr, while still enumerating the rest. Relatedly, `to_json`'s anti-shrink guard (#479) now fails safe: a non-empty but unreadable existing `graph.json` refuses the overwrite (pass `force=True` to override) instead of silently clobbering a good graph; an empty file still proceeds.
+7 -2
View File
@@ -2173,8 +2173,13 @@ def _resolve_java_type_references(
# `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"}
# so their edges correctly stay on their stub. `references` (field/parameter/
# return-type uses) is included so a cross-module reference to a same-named
# class doesn't dangle on a sourceless phantom node when two packages define
# the same simple name — the node itself survives with a path-scoped id, but
# the reference must point at the RIGHT one (#1744). Mirrors the C# resolver,
# whose REPOINT set already covers `references`.
REPOINT_RELATIONS = {"implements", "inherits", "extends", "imports", "references"}
repointed_from: set[str] = set()
for edge in all_edges:
if edge.get("relation") not in REPOINT_RELATIONS:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.9.11"
version = "0.9.12"
description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
+52
View File
@@ -77,6 +77,58 @@ def test_java_ambiguous_implements_disambiguated_by_import(tmp_path: Path):
assert "com/b/handler" not in tgt["source_file"]
def test_java_ambiguous_reference_disambiguated_by_import(tmp_path: Path):
# #1744: two classes with the SAME simple name in different modules/packages.
# Both survive as distinct path-scoped nodes, but a cross-module field/type
# `references` edge used to dangle on a sourceless phantom stub (the
# implements/inherits case was handled, references was not). The importing
# file's `import` must re-point the reference to the right class and leave no
# orphan phantom.
payment = _write(
tmp_path / "payment/src/com/example/payment/FinancialEntryValidator.java",
"package com.example.payment;\n"
"public class FinancialEntryValidator {\n"
" public boolean validateCurrency(String c) { return c.length() == 3; }\n"
"}\n",
)
core = _write(
tmp_path / "core/src/com/example/core/FinancialEntryValidator.java",
"package com.example.core;\n"
"public class FinancialEntryValidator {\n"
" public void auditEntry(String id) {}\n"
"}\n",
)
consumer = _write(
tmp_path / "app/src/com/example/app/PaymentService.java",
"package com.example.app;\n"
"import com.example.payment.FinancialEntryValidator;\n"
"public class PaymentService {\n"
" private FinancialEntryValidator validator = new FinancialEntryValidator();\n"
"}\n",
)
result = extract([payment, core, consumer], cache_root=tmp_path)
# Both real classes survive (path-scoped ids); no sourceless phantom remains.
fev = [n for n in result["nodes"] if n.get("label") == "FinancialEntryValidator"]
reals = [n for n in fev if n.get("source_file")]
phantoms = [n for n in fev if not n.get("source_file")]
assert len(reals) == 2, f"expected both real classes, got {[n.get('source_file') for n in fev]}"
assert not phantoms, f"orphan phantom node(s) remain: {[n['id'] for n in phantoms]}"
# The reference must resolve to the IMPORTED (payment) class, not core.
refs = [
e for e in result["edges"]
if e["relation"] == "references"
and (_node_by_id(result, e["target"]) or {}).get("label") == "FinancialEntryValidator"
]
assert refs, "expected a references edge to FinancialEntryValidator"
for e in refs:
tgt = _node_by_id(result, e["target"])
assert tgt is not None and tgt.get("source_file")
assert "payment/" in tgt["source_file"]
assert "core/" 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.