diff --git a/graphify/extract.py b/graphify/extract.py index 7b23f1f9..237bb453 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 diff --git a/graphify/extractors/go.py b/graphify/extractors/go.py index d5654a5f..b2483db1 100644 --- a/graphify/extractors/go.py +++ b/graphify/extractors/go.py @@ -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), + } diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 552f4411..01a946a2 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -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], diff --git a/tests/test_extract.py b/tests/test_extract.py index 15f8273b..693c1a25 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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): diff --git a/tests/test_go_qualified_resolution.py b/tests/test_go_qualified_resolution.py new file mode 100644 index 00000000..8a0d367f --- /dev/null +++ b/tests/test_go_qualified_resolution.py @@ -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"] + )