diff --git a/CHANGELOG.md b/CHANGELOG.md index 057e32f3b..13ea23f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) -## 0.9.12 (unreleased) +## 0.9.13 (unreleased) + +- Fix: cross-module references to a function now resolve to its definition instead of dangling on a name-only stub (#1781, thanks @EmilNyg). `_rewire_unique_stub_nodes` gated merge targets through `_is_type_like_definition`, which rejects any label ending in `)` — so function/method defs could never absorb their reference stubs, and "who references this function" returned nothing on the definition node while a sourceless stub held all the edges. Top-level function defs are now eligible rewire targets when the label match is globally unique, gated by a language-family match with the referrers (a Python `get_db` reference can't bind to a unique Go `get_db()`) and excluding stubs used as a supertype (`inherits`/`implements`/`extends` — you don't inherit from a function). Types are unchanged. - Fix: live PostgreSQL introspection (`--postgres`) now emits foreign-key `references` edges under a read-only role (#1746, thanks @rithyKabir). The FK query read `information_schema.referential_constraints`, which is privilege-filtered — a role with only SELECT sees zero FK rows while tables/views/routines still appear, so every `references` edge silently vanished. It now reads the world-readable `pg_catalog.pg_constraint` (keyed by oid, which also fixes same-named constraints on sibling tables cross-matching in the old name-based joins), preserving composite-FK column order via `UNNEST ... WITH ORDINALITY`. diff --git a/graphify/extract.py b/graphify/extract.py index dbef91ea0..c88d17154 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1805,10 +1805,27 @@ def _node_label_key(node: dict, fold: bool = False) -> str: return key.lower() if fold else key +def _is_top_level_function_definition(node: dict) -> bool: + """A free/top-level function def (label ``name()``), not a method or type. + + Methods carry a leading dot (``.foo()``) or a qualifier (``Class.foo()``); + excluding those keeps a bare-name reference from binding to a receiver-scoped + method, which the receiver-typed resolvers own (#1781). + """ + label = str(node.get("label", "")).strip() + return ( + node.get("file_type") == "code" + and label.endswith(")") + and not label.startswith(".") + and "." not in label + ) + + def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: """Map unresolved no-source stubs to a unique real definition with the same label.""" - real_by_label: dict[str, list[dict]] = {} # exact-case (all languages) + real_by_label: dict[str, list[dict]] = {} # exact-case type-like (all languages) real_by_label_ci: dict[str, list[dict]] = {} # case-INSENSITIVE-language reals only + func_by_label: dict[str, list[dict]] = {} # top-level function defs (#1781) stubs: list[dict] = [] for node in nodes: @@ -1824,9 +1841,34 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: if _lang_is_case_insensitive(node.get("source_file")): real_by_label_ci.setdefault( _node_label_key(node, fold=True), []).append(node) + elif _is_top_level_function_definition(node): + func_by_label.setdefault(key, []).append(node) continue stubs.append(node) + # Language families referencing each stub, for the function-merge guard (#1781): + # a cross-module `references` edge to a function used to dangle on a sourceless + # name-only stub because functions were excluded as rewire targets. We now allow + # a UNIQUE function definition to absorb it, but only when it shares a language + # family with the stub's referrers — so a Python `get_db` reference can't bind to + # a unique Go `get_db()` (mirrors the #1718/#1749 interop guard). + stub_ids = {str(s.get("id")) for s in stubs if s.get("id")} + stub_families: dict[str, set] = {} + supertype_stub_ids: set[str] = set() # stubs used as a base type — never a function + _SUPERTYPE_RELATIONS = {"inherits", "implements", "extends"} + for edge in edges: + rel = edge.get("relation") + for endpoint in ("source", "target"): + nid = edge.get(endpoint) + if nid in stub_ids: + fam = _lang_family(edge.get("source_file")) + if fam is not None: + stub_families.setdefault(str(nid), set()).add(fam) + # A stub referenced as a supertype must resolve to a class/type, + # not a same-named function (you don't inherit from a function). + if endpoint == "target" and rel in _SUPERTYPE_RELATIONS: + supertype_stub_ids.add(str(nid)) + remap: dict[str, str] = {} for stub in stubs: stub_id = str(stub.get("id", "")) @@ -1834,12 +1876,22 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: continue candidates = real_by_label.get(_node_label_key(stub), []) if len(candidates) != 1: - # No unique exact match — fall back to a case-insensitive match, but + # No unique exact type match — fall back to a case-insensitive match, but # only against case-insensitive-language definitions (so a case-sensitive # `PATH` can never absorb a `Path` reference). candidates = real_by_label_ci.get(_node_label_key(stub, fold=True), []) - if len(candidates) != 1: - continue + if len(candidates) != 1: + # #1781: no unique type — try a unique top-level FUNCTION definition, + # gated by (a) the stub not being used as a supertype and (b) a + # language-family match with the stub's referrers. + fcands = func_by_label.get(_node_label_key(stub), []) + if len(fcands) == 1 and stub_id not in supertype_stub_ids: + fams = stub_families.get(stub_id, set()) + cand_fam = _lang_family(fcands[0].get("source_file")) + if not fams or cand_fam is None or cand_fam in fams: + candidates = fcands + if len(candidates) != 1: + continue target_id = candidates[0].get("id") if isinstance(target_id, str) and target_id and target_id != stub_id: remap[stub_id] = target_id diff --git a/pyproject.toml b/pyproject.toml index 510e77469..50a7b63d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.12" +version = "0.9.13" 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_extract.py b/tests/test_extract.py index 6247ec59c..0fd4030aa 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1959,3 +1959,64 @@ def test_matlab_m_not_extracted_as_garbage(tmp_path, capsys): result = extract([m], cache_root=tmp_path) assert result["nodes"] == [] # no garbage ObjC nodes assert "no AST extractor" in capsys.readouterr().err # surfaced, not silent + + +def test_rewire_binds_cross_module_function_reference_to_definition(): + """#1781: a cross-module reference to a function must land on the real + definition, not a sourceless name-only stub (functions were excluded as + rewire targets).""" + from graphify.extract import _rewire_unique_stub_nodes + nodes = [ + {"id": "pkg_dep_get_db", "label": "get_db()", "file_type": "code", + "source_file": "pkg/dep.py", "source_location": "L1"}, + {"id": "get_db", "label": "get_db()", "file_type": "code", "source_file": ""}, + ] + edges = [{"source": "pkg_ep_route", "target": "get_db", "relation": "references", + "source_file": "pkg/ep.py", "weight": 1.0}] + _rewire_unique_stub_nodes(nodes, edges) + assert edges[0]["target"] == "pkg_dep_get_db" + assert "get_db" not in {n["id"] for n in nodes} # stub dropped + + +def test_rewire_does_not_bind_function_reference_across_language(): + """#1781 safety: a Python reference stub must not bind to a unique Go + function of the same name (mirrors the #1749 interop guard).""" + from graphify.extract import _rewire_unique_stub_nodes + nodes = [ + {"id": "svc_get_db", "label": "get_db()", "file_type": "code", + "source_file": "svc/main.go", "source_location": "L1"}, + {"id": "get_db", "label": "get_db()", "file_type": "code", "source_file": ""}, + ] + edges = [{"source": "app_route", "target": "get_db", "relation": "references", + "source_file": "app/route.py", "weight": 1.0}] + _rewire_unique_stub_nodes(nodes, edges) + assert edges[0]["target"] == "get_db" # unchanged — cross-language blocked + + +def test_rewire_does_not_bind_ambiguous_function_reference(): + """#1781 safety: two same-named functions leave the reference on the stub.""" + from graphify.extract import _rewire_unique_stub_nodes + nodes = [ + {"id": "a_get_db", "label": "get_db()", "file_type": "code", "source_file": "a.py", "source_location": "L1"}, + {"id": "b_get_db", "label": "get_db()", "file_type": "code", "source_file": "b.py", "source_location": "L1"}, + {"id": "get_db", "label": "get_db()", "file_type": "code", "source_file": ""}, + ] + edges = [{"source": "c_route", "target": "get_db", "relation": "references", + "source_file": "c.py", "weight": 1.0}] + _rewire_unique_stub_nodes(nodes, edges) + assert edges[0]["target"] == "get_db" # ambiguous — not merged + + +def test_rewire_does_not_bind_supertype_stub_to_function(): + """#1781 safety: a stub used as a base type must never resolve to a + same-named, same-language function.""" + from graphify.extract import _rewire_unique_stub_nodes + nodes = [ + {"id": "factory_BookStore", "label": "BookStore()", "file_type": "code", + "source_file": "factory.py", "source_location": "L1"}, + {"id": "BookStore", "label": "BookStore", "file_type": "code", "source_file": ""}, + ] + edges = [{"source": "store_Sqlite", "target": "BookStore", "relation": "inherits", + "source_file": "store.py", "weight": 1.0}] + _rewire_unique_stub_nodes(nodes, edges) + assert edges[0]["target"] == "BookStore" # inherits stub not bound to function