fix(go): resolve qualified symbols by import path

Prevent package-qualified calls and types from collapsing onto unrelated bare-name symbols. Preserve Go import evidence, resolve internal packages exactly, canonicalize external type stubs, and support incremental resolution context.

Investigation, implementation, and regression fixtures prepared with OpenAI Codex.
This commit is contained in:
BackendDev
2026-08-11 14:40:01 +01:00
committed by safishamsi
parent 5fd4ab650b
commit e4d132b7da
5 changed files with 393 additions and 17 deletions
+33 -1
View File
@@ -79,6 +79,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
_decldef_class_stem,
_disambiguate_colliding_node_ids,
_find_workspace_root,
_go_import_path_for_file,
_is_type_like_definition,
_js_call_identifier,
_js_default_export_name,
@@ -116,6 +117,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
_resolve_cross_file_imports,
_resolve_cross_file_java_imports,
_resolve_export_target,
_resolve_go_type_references,
_resolve_java_type_references,
_resolve_php_type_references,
_resolve_js_import_path,
@@ -5951,6 +5953,21 @@ def extract(
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Java type-reference resolution failed, skipping: %s", exc)
# Resolve internal Go pkg.Type references exactly and park external ones
# before the generic bare-label stub rewire can manufacture a collision.
_go_sel = [(r, p) for r, p in zip(per_file, paths) if p.suffix == ".go"]
if _go_sel:
try:
_resolve_go_type_references(
[r for r, _ in _go_sel], [p for _, p in _go_sel],
all_nodes, all_edges, root,
resolution_context_nodes, resolution_context_edges,
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"Go type-reference resolution failed, skipping: %s", exc
)
_rewire_unique_stub_nodes(all_nodes, all_edges)
# Add cross-file class-level edges (Python only - uses Python parser internally)
@@ -6168,6 +6185,7 @@ def extract(
# file is real ONLY if the caller imported it. So a cross-file call from one
# of these files with no import evidence is gated below (#1659).
_JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
_go_module_cache: dict[Path, str | None] = {}
for rc in all_raw_calls:
callee = rc.get("callee", "")
if not callee:
@@ -6225,6 +6243,20 @@ def extract(
]
if not candidates:
continue
# Imported Go selectors carry exact package evidence. External package
# calls yield no internal candidate instead of binding by bare name.
go_exact_import = False
if rc.get("language") == "go" and rc.get("import_path"):
import_path = str(rc["import_path"])
candidates = [
candidate for candidate in candidates
if _go_import_path_for_file(
nid_to_source_file.get(candidate, ""), root, _go_module_cache
) == import_path
]
if not candidates:
continue
go_exact_import = True
caller = rc["caller_nid"]
# Resolve the caller's file via the raw_call's own source_file string,
# which is stable regardless of any caller_nid remap. An indirect
@@ -6251,7 +6283,7 @@ def extract(
if len(candidates) == 1:
tgt = candidates[0]
has_import_evidence = _has_import_evidence(tgt)
has_import_evidence = go_exact_import or _has_import_evidence(tgt)
else:
# Ambiguous name (defined in 2+ files). Don't bail outright (#1219):
# if the caller has explicit import evidence pointing at exactly one
+23 -7
View File
@@ -52,8 +52,10 @@ def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[st
out.append((text, "generic_arg" if generic else "type"))
return
if t == "qualified_type":
text = _read_text(node, source).rsplit(".", 1)[-1]
if text and text not in _GO_PREDECLARED_TYPES:
# Keep the package qualifier so the generic stub rewire cannot attach
# `testing.T` to an unrelated local type or function named T.
text = _read_text(node, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
return
if t == "generic_type":
@@ -105,7 +107,8 @@ def extract_go(path: Path) -> dict:
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, object]] = []
go_imported_pkgs: set[str] = set() # local names of imported packages
# local package name (including aliases) -> written Go import path
go_imported_pkgs: dict[str, str] = {}
def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
@@ -338,7 +341,7 @@ def extract_go(path: Path) -> dict:
alias = spec.child_by_field_name("name")
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
if local_name and local_name != "_" and local_name != ".":
go_imported_pkgs.add(local_name)
go_imported_pkgs[local_name] = raw
elif child.type == "import_spec":
path_node = child.child_by_field_name("path")
if path_node:
@@ -348,7 +351,7 @@ def extract_go(path: Path) -> dict:
alias = child.child_by_field_name("name")
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
if local_name and local_name != "_" and local_name != ".":
go_imported_pkgs.add(local_name)
go_imported_pkgs[local_name] = raw
return
for child in node.children:
@@ -373,6 +376,8 @@ def extract_go(path: Path) -> dict:
callee_name: str | None = None
is_member_call: bool = False
is_bare_identifier: bool = False
package_receiver: str | None = None
import_path: str | None = None
if func_node:
if func_node.type == "identifier":
is_bare_identifier = True
@@ -384,6 +389,9 @@ def extract_go(path: Path) -> dict:
# Package-qualified call (e.g. fmt.Println) → allow cross-file resolution.
# Receiver method call (e.g. s.logger.Log) → skip, no import evidence.
is_member_call = receiver_name not in go_imported_pkgs
if not is_member_call:
package_receiver = receiver_name
import_path = go_imported_pkgs[receiver_name]
if field:
callee_name = _read_text(field, source)
if is_bare_identifier and callee_name in _GO_PREDECLARED_FUNCS:
@@ -393,7 +401,8 @@ def extract_go(path: Path) -> dict:
# of raw_calls, so the cross-file pass cannot bind it either.
callee_name = None
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
tgt_nid = label_to_nid.get(callee_name)
# Never resolve an imported selector through a bare local name.
tgt_nid = None if import_path else label_to_nid.get(callee_name)
if tgt_nid and tgt_nid != caller_nid:
pair = (caller_nid, tgt_nid)
if pair not in seen_call_pairs:
@@ -415,6 +424,8 @@ def extract_go(path: Path) -> dict:
"callee": callee_name,
"is_member_call": is_member_call,
"language": "go",
"receiver": package_receiver,
"import_path": import_path,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
@@ -431,4 +442,9 @@ def extract_go(path: Path) -> dict:
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
clean_edges.append(edge)
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
return {
"nodes": nodes,
"edges": clean_edges,
"raw_calls": raw_calls,
"go_imports": dict(go_imported_pkgs),
}
+151
View File
@@ -2316,6 +2316,157 @@ def _resolve_cross_file_java_imports(
return new_edges
def _go_import_path_for_file(
source_file: str | Path,
root: Path,
module_cache: dict[Path, str | None] | None = None,
) -> str | None:
"""Return the canonical Go import path for a source file inside a module."""
cache = module_cache if module_cache is not None else {}
path = Path(source_file)
if not path.is_absolute():
path = root / path
try:
directory = path.resolve().parent
except OSError:
directory = path.absolute().parent
module_dir: Path | None = None
module_path: str | None = None
for candidate in (directory, *directory.parents):
if candidate in cache:
cached = cache[candidate]
if cached:
module_dir, module_path = candidate, cached
break
go_mod = candidate / "go.mod"
if not go_mod.is_file():
continue
try:
match = re.search(
r"(?m)^\s*module\s+([^\s]+)",
go_mod.read_text(encoding="utf-8"),
)
except (OSError, UnicodeError):
match = None
module_dir = candidate
module_path = match.group(1) if match else None
cache[candidate] = module_path
break
if not module_dir or not module_path:
return None
try:
relative_dir = directory.relative_to(module_dir)
except ValueError:
return None
suffix = relative_dir.as_posix()
return module_path if suffix == "." else f"{module_path}/{suffix}"
def _resolve_go_type_references(
per_file: list[dict],
paths: list[Path],
all_nodes: list[dict],
all_edges: list[dict],
root: Path,
resolution_context_nodes: list[dict] | None = None,
resolution_context_edges: list[dict] | None = None,
) -> None:
"""Resolve qualified Go types through aliases and exact module paths."""
imports_by_file: dict[str, dict[str, str]] = {}
actual_path_by_file: dict[str, Path] = {}
for path, result in zip(paths, per_file):
imports = result.get("go_imports") or {}
for node in result.get("nodes", []):
source_file = node.get("source_file")
if source_file:
imports_by_file[str(source_file)] = imports
actual_path_by_file[str(source_file)] = path
if not imports_by_file:
return
definition_nodes = all_nodes + (resolution_context_nodes or [])
definition_edges = all_edges + (resolution_context_edges or [])
contained = {edge.get("target") for edge in definition_edges
if edge.get("relation") == "contains"}
module_cache: dict[Path, str | None] = {}
fqn_to_ids: dict[str, list[str]] = {}
for node in definition_nodes:
source_file = str(node.get("source_file") or "")
label = str(node.get("label") or "")
nid = node.get("id")
if (not source_file or not label or not nid or nid not in contained
or not _is_type_like_definition(node)):
continue
actual_path = actual_path_by_file.get(source_file, Path(source_file))
package_path = _go_import_path_for_file(actual_path, root, module_cache)
if package_path:
fqn_to_ids.setdefault(f"{package_path}.{label}", []).append(nid)
qualified_stubs = {
node["id"]: str(node.get("label") or "")
for node in all_nodes
if node.get("id") and not node.get("source_file")
and "." in str(node.get("label") or "")
}
if not qualified_stubs:
return
node_ids = {node.get("id") for node in all_nodes if node.get("id")}
external_stub_ids: dict[str, str] = {}
new_nodes: list[dict] = []
def external_stub(fqn: str) -> str:
existing = external_stub_ids.get(fqn)
if existing:
return existing
nid = _make_id("go", "type", fqn)
if nid not in node_ids:
new_nodes.append({
"id": nid,
"label": fqn,
"file_type": "code",
"source_file": "",
"source_location": "",
})
node_ids.add(nid)
external_stub_ids[fqn] = nid
return nid
repointed_from: set[str] = set()
for edge in all_edges:
if edge.get("relation") not in {"references", "embeds"}:
continue
target = edge.get("target")
qualified = qualified_stubs.get(target)
if not qualified:
continue
alias, _, type_name = qualified.rpartition(".")
import_path = imports_by_file.get(
str(edge.get("source_file") or ""), {}
).get(alias)
if not import_path or not type_name:
continue
fqn = f"{import_path}.{type_name}"
candidates = fqn_to_ids.get(fqn, [])
edge["target"] = candidates[0] if len(candidates) == 1 else external_stub(fqn)
repointed_from.add(str(target))
if new_nodes:
all_nodes.extend(new_nodes)
if not repointed_from:
return
referenced = {endpoint for edge in all_edges
for endpoint in (edge.get("source"), edge.get("target"))}
all_nodes[:] = [
node for node in all_nodes
if node.get("id") not in repointed_from or node.get("id") in referenced
]
def _resolve_java_type_references(
per_file: list[dict],
paths: list[Path],
+14 -9
View File
@@ -238,12 +238,14 @@ def test_origin_file_is_not_serialized_into_extract_output(tmp_path):
def test_go_imported_type_stubs_do_not_collide_across_source_files(tmp_path):
"""#1462 (dedicated extractors): the imported-type-stub disambiguation (the
``origin_file`` key) landed only in the generic extractor, so the six dedicated
extractors (Go, Rust, Julia, Fortran, PowerShell, ObjC) still collapsed same-label
cross-file stubs into one conflated bare-id node a false cross-package link.
They must stay distinct per file while keeping ``source_file`` empty so the #1402
rewire still collapses them onto a real definition when one exists."""
"""Go external types use their import path as canonical identity.
#1462 kept unresolved bare stubs distinct per source file because Graphify
could not tell whether they named the same external package. The Go
import-aware resolver now has that evidence: two ``ext.Widget`` references
intentionally share one sourceless node without colliding with a local
``Widget`` definition.
"""
first = tmp_path / "a/use_a.go"
second = tmp_path / "b/use_b.go"
first.parent.mkdir(parents=True)
@@ -252,11 +254,14 @@ def test_go_imported_type_stubs_do_not_collide_across_source_files(tmp_path):
second.write_text('package b\n\nimport "ext"\n\nfunc UseB(w ext.Widget) {}\n', encoding="utf-8")
result = extract([first, second], cache_root=tmp_path)
widget_nodes = [node for node in result["nodes"] if node["label"] == "Widget"]
widget_nodes = [node for node in result["nodes"] if node["label"] == "ext.Widget"]
assert len(widget_nodes) == 2
assert len({node["id"] for node in widget_nodes}) == 2
assert len(widget_nodes) == 1
assert all(not node.get("source_file") for node in widget_nodes)
target = widget_nodes[0]["id"]
refs = [edge for edge in result["edges"] if edge.get("relation") == "references"]
assert len(refs) == 2
assert all(edge["target"] == target for edge in refs)
def test_extract_updates_raw_call_callers_after_duplicate_id_disambiguation(tmp_path):
+172
View File
@@ -0,0 +1,172 @@
"""Regression coverage for package-qualified Go calls and type references."""
from pathlib import Path
from graphify.extract import extract
def _extract(root: Path) -> dict:
return extract(
sorted(root.rglob("*.go")),
cache_root=root,
root=root,
parallel=False,
)
def _ids(result: dict, *, label: str, suffix: str) -> set[str]:
return {
node["id"]
for node in result["nodes"]
if str(node.get("source_file", "")).endswith(suffix)
and str(node.get("label", "")).strip(".()") == label
}
def test_external_package_new_does_not_bind_to_local_new(tmp_path: Path) -> None:
"""``errors.New`` must not become a call to an unrelated local ``New``."""
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
(tmp_path / "factory.go").write_text("package repro\n\nfunc New() int { return 1 }\n")
(tmp_path / "worker.go").write_text(
'package repro\n\nimport "errors"\n\nfunc Build() error { return errors.New("boom") }\n'
)
result = _extract(tmp_path)
build_ids = _ids(result, label="Build", suffix="worker.go")
local_new_ids = _ids(result, label="New", suffix="factory.go")
phantom = [
edge
for edge in result["edges"]
if edge.get("relation") == "calls"
and edge.get("source") in build_ids
and edge.get("target") in local_new_ids
]
assert phantom == [], f"errors.New bound to the local New: {phantom}"
def test_internal_aliased_package_new_resolves_exact_import(tmp_path: Path) -> None:
"""An imported internal package selector resolves despite same-name decoys."""
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
factory = tmp_path / "factory"
factory.mkdir()
(factory / "factory.go").write_text("package factory\n\nfunc New() int { return 1 }\n")
app = tmp_path / "app"
app.mkdir()
(app / "decoy.go").write_text("package app\n\nfunc New() int { return 2 }\n")
(app / "worker.go").write_text(
'package app\n\nimport maker "example.com/repro/factory"\n\n'
"func Build() int { return maker.New() }\n"
)
result = _extract(tmp_path)
build_ids = _ids(result, label="Build", suffix="app/worker.go")
factory_new_ids = _ids(result, label="New", suffix="factory/factory.go")
decoy_new_ids = _ids(result, label="New", suffix="app/decoy.go")
calls = [
edge
for edge in result["edges"]
if edge.get("relation") == "calls" and edge.get("source") in build_ids
]
assert len(calls) == 1, calls
assert calls[0]["target"] in factory_new_ids
assert calls[0]["target"] not in decoy_new_ids
assert calls[0]["confidence"] == "EXTRACTED"
def test_external_qualified_type_does_not_bind_to_local_function(tmp_path: Path) -> None:
"""``*testing.T`` must not reference an unrelated local function ``T``."""
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
(tmp_path / "translate.go").write_text(
"package repro\n\nfunc T(message string) string { return message }\n"
)
(tmp_path / "worker_test.go").write_text(
'package repro\n\nimport "testing"\n\nfunc TestBuild(t *testing.T) {}\n'
)
result = _extract(tmp_path)
test_ids = _ids(result, label="TestBuild", suffix="worker_test.go")
local_t_ids = _ids(result, label="T", suffix="translate.go")
refs = [
edge
for edge in result["edges"]
if edge.get("relation") == "references" and edge.get("source") in test_ids
]
assert refs
assert all(edge.get("target") not in local_t_ids for edge in refs), refs
nodes = {node["id"]: node for node in result["nodes"]}
assert any(nodes[edge["target"]]["label"] == "testing.T" for edge in refs)
def test_internal_qualified_type_resolves_exact_import(tmp_path: Path) -> None:
"""An aliased internal qualified type points to its package definition."""
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
model = tmp_path / "model"
model.mkdir()
(model / "user.go").write_text("package model\n\ntype User struct{}\n")
app = tmp_path / "app"
app.mkdir()
(app / "decoy.go").write_text("package app\n\ntype User struct{}\n")
(app / "handler.go").write_text(
'package app\n\nimport domain "example.com/repro/model"\n\n'
"func Handle(user *domain.User) {}\n"
)
result = _extract(tmp_path)
handle_ids = _ids(result, label="Handle", suffix="app/handler.go")
model_user_ids = _ids(result, label="User", suffix="model/user.go")
decoy_user_ids = _ids(result, label="User", suffix="app/decoy.go")
refs = [
edge
for edge in result["edges"]
if edge.get("relation") == "references" and edge.get("source") in handle_ids
]
assert len(refs) == 1, refs
assert refs[0]["target"] in model_user_ids
assert refs[0]["target"] not in decoy_user_ids
def test_incremental_qualified_resolution_uses_unchanged_context(tmp_path: Path) -> None:
"""Changed callers still resolve calls and types defined in unchanged files."""
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
factory = tmp_path / "factory"
factory.mkdir()
target = factory / "factory.go"
target.write_text(
"package factory\n\ntype User struct{}\n\n"
"func New() *User { return &User{} }\n"
)
app = tmp_path / "app"
app.mkdir()
caller = app / "worker.go"
caller.write_text(
'package app\n\nimport maker "example.com/repro/factory"\n\n'
"func Build() *maker.User { return maker.New() }\n"
)
full = _extract(tmp_path)
caller.write_text(caller.read_text() + "\n")
changed = extract(
[caller],
cache_root=tmp_path,
root=tmp_path,
parallel=False,
resolution_context_nodes=full["nodes"],
resolution_context_edges=full["edges"],
)
build_ids = _ids(changed, label="Build", suffix="app/worker.go")
full_new_ids = _ids(full, label="New", suffix="factory/factory.go")
full_user_ids = _ids(full, label="User", suffix="factory/factory.go")
assert any(
edge.get("relation") == "calls"
and edge.get("source") in build_ids
and edge.get("target") in full_new_ids
for edge in changed["edges"]
)
assert any(
edge.get("relation") == "references"
and edge.get("source") in build_ids
and edge.get("target") in full_user_ids
for edge in changed["edges"]
)