diff --git a/CHANGELOG.md b/CHANGELOG.md index f251746..6c85a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 528c706..d46b4cb 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 6e6c54f..510e774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_java_type_resolution.py b/tests/test_java_type_resolution.py index 3522977..7729749 100644 --- a/tests/test_java_type_resolution.py +++ b/tests/test_java_type_resolution.py @@ -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.