From 8cfda7f1ce4eeda957e068ccfb757bd5b853903e Mon Sep 17 00:00:00 2001 From: madhusudanpatnaik Date: Fri, 23 Oct 2026 18:15:20 +0100 Subject: [PATCH] fix(go): extract `type A = B` aliases as type nodes (#4579) Applied from PR #4579 (authorship preserved); stale cache-schema bump dropped for one combined bump. Co-Authored-By: Claude Opus 4.8 --- graphify/extractors/go.py | 31 ++++++- tests/test_go_type_alias.py | 171 ++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 tests/test_go_type_alias.py diff --git a/graphify/extractors/go.py b/graphify/extractors/go.py index 67e43e9ba..b7e6455f3 100644 --- a/graphify/extractors/go.py +++ b/graphify/extractors/go.py @@ -48,6 +48,21 @@ _GO_PREDECLARED_FUNCS = frozenset({ "recover", }) +def _go_alias_re_exports(alias_node, source: bytes) -> bool: + """Whether `type A = pkg.A` (or `pkg.A[...]`) re-exports another package's type + under its own name. Such an alias denotes that very type and declares no new + name, so it gets no node: references to `A` keep resolving to the original + instead of tying between the two. Any other alias introduces a name.""" + target = alias_node.child_by_field_name("type") + if target is not None and target.type == "generic_type": + target = target.child_by_field_name("type") + if target is None or target.type != "qualified_type": + return False + name, target_name = alias_node.child_by_field_name("name"), target.child_by_field_name("name") + return (name is not None and target_name is not None + and _read_text(name, source) == _read_text(target_name, source)) + + def _go_type_param_names(decl_node, source: bytes) -> frozenset[str]: """Names bound by a declaration's generic type parameters. @@ -393,8 +408,11 @@ def extract_go(path: Path) -> dict: def _scan_type_names(node) -> None: # Types first: a method is keyed under its owner's final id, which is - # salted when the owner's own name collides. - if node.type == "type_spec": + # salted when the owner's own name collides. An alias (`type A = B`) is a + # `type_alias`, not a `type_spec`, and declares a type name all the same. + if node.type == "type_spec" or ( + node.type == "type_alias" and not _go_alias_re_exports(node, source) + ): name_node = node.child_by_field_name("name") if name_node is not None: name = _read_text(name_node, source) @@ -509,7 +527,9 @@ def extract_go(path: Path) -> dict: if t == "type_declaration": for child in node.children: - if child.type != "type_spec": + if child.type not in ("type_spec", "type_alias"): + continue + if child.type == "type_alias" and _go_alias_re_exports(child, source): continue name_node = child.child_by_field_name("name") if not name_node: @@ -520,6 +540,11 @@ def extract_go(path: Path) -> dict: add_node(type_nid, type_name, line) callable_class_nids.add(type_nid) add_edge(file_nid, type_nid, "contains", line) + if child.type == "type_alias": + # `type A = B` is a declaration like `type A B`: references to + # it land here instead of minting a sourceless stub. Its + # right-hand side is not walked. + continue # A generic type's own parameters (`type Box[T any]`) are local # bindings; a field or method using `T` must not mint a stub. type_params = _go_type_param_names(child, source) diff --git a/tests/test_go_type_alias.py b/tests/test_go_type_alias.py new file mode 100644 index 000000000..eb0276294 --- /dev/null +++ b/tests/test_go_type_alias.py @@ -0,0 +1,171 @@ +"""A Go type alias (`type A = B`) is a declaration, like a defined type. + +tree-sitter-go parses `type A = B` as a `type_alias`, not a `type_spec`, and +extract_go walked `type_spec` only. The alias was dropped, so a field, +parameter or return typed with it, even below the declaration, resolved to a +SOURCELESS stub that the corpus rewire can grow into a cross-file hub. A method +declared on an alias receiver (`func (a Alias) M()`) minted the alias's node at +the method's line instead. An alias is now a type node on its own line, like a +defined type: the same id, the same collision census and the same conversion +semantics (`ID("x")` as `Named("y")`). Its right-hand side is not walked. +""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +from graphify.extract import extract + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("tree_sitter_go") is None, + reason="tree_sitter_go not installed", +) + +_SOURCE = """\ +package p + +type Handler = func(int) int +type Pair[T any] = struct{ a, b T } +type ( + ID = string + Name string +) +type Base struct{} +type Alias = Base + +type Holder struct { + h Handler + id ID + p Pair[int] +} + +func (a Alias) M() {} + +func Use(h Handler, id ID) ID { + _ = ID("x") + _ = Name("y") + return id +} +""" + + +def _extract_file(tmp_path: Path, source: str = _SOURCE) -> dict: + from graphify.extractors.go import extract_go + + path = tmp_path / "p" / "a.go" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + return extract_go(path) + + +def _labels(result: dict) -> dict[str, str]: + return {n["id"]: n["label"] for n in result["nodes"]} + + +def _pairs(result: dict, relation: str) -> set[tuple[str, str]]: + label = _labels(result) + return {(label.get(e["source"], str(e["source"])), label.get(e["target"], str(e["target"]))) + for e in result["edges"] if e["relation"] == relation} + + +def test_each_alias_is_a_sourced_node_on_its_own_line(tmp_path: Path): + result = _extract_file(tmp_path) + for label, line in (("Handler", "L3"), ("Pair", "L4"), ("ID", "L6"), ("Alias", "L10")): + [node] = [n for n in result["nodes"] if n["label"] == label] + assert node["source_file"], label + assert node["source_location"] == line, label + assert [n["label"] for n in result["nodes"] if not n.get("source_file")] == [] + + +def test_each_alias_is_contained_by_its_file(tmp_path: Path): + contained = {tgt for src, tgt in _pairs(_extract_file(tmp_path), "contains")} + assert {"Handler", "Pair", "ID", "Alias"} <= contained + + +def test_references_bind_to_the_local_alias(tmp_path: Path): + refs = _pairs(_extract_file(tmp_path), "references") + assert {("Holder", "Handler"), ("Holder", "ID"), ("Holder", "Pair"), + ("Use()", "Handler"), ("Use()", "ID")} <= refs + + +def test_a_method_on_an_alias_receiver_hangs_off_the_alias_declaration(tmp_path: Path): + result = _extract_file(tmp_path) + assert ("Alias", ".M()") in _pairs(result, "method") + [alias] = [n for n in result["nodes"] if n["label"] == "Alias"] + assert alias["source_location"] == "L10" + + +def test_an_alias_conversion_is_treated_like_a_defined_type_conversion(tmp_path: Path): + calls = _pairs(_extract_file(tmp_path), "calls") + assert ("Use()", "Name") in calls + assert ("Use()", "ID") in calls + + +def test_a_case_distinct_alias_and_type_stay_separate(tmp_path: Path): + result = _extract_file(tmp_path, "package p\n\ntype ID = string\ntype Id struct{}\n") + ids = [n for n in result["nodes"] if n["label"] in ("ID", "Id")] + assert len(ids) == 2 + assert len({n["id"] for n in ids}) == 2 + + +def test_a_reference_from_another_file_of_the_package_lands_on_the_alias(tmp_path: Path): + files = { + "p/types.go": "package p\n\ntype Handler = func(int) int\n", + "p/use.go": "package p\n\ntype Button struct {\n\th Handler\n}\n", + } + for name, body in files.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(body, encoding="utf-8") + result = extract([tmp_path / n for n in files], root=tmp_path, + cache_root=tmp_path / "graphify-out", parallel=False) + [handler] = [n for n in result["nodes"] if n["label"] == "Handler"] + assert handler["source_file"].endswith("types.go") + label = _labels(result) + targets = {e["target"] for e in result["edges"] + if e["relation"] == "references" and label.get(e["source"]) == "Button"} + assert targets == {handler["id"]} + + +def test_a_same_name_re_export_alias_resolves_to_the_original(tmp_path: Path): + """`type Field = zapcore.Field` re-exports another package's type under its + own name: the alias denotes that very type. It emits no node, so references + to `Field` keep resolving to the one real `Field` instead of tying between + the alias and the original (uber-go/zap's shape).""" + files = { + "core/field.go": "package core\n\ntype Field struct{ Key string }\n", + "zap/field.go": ('package zap\n\nimport "example.com/core"\n\n' + "type Field = core.Field\n\nfunc String(k string) Field { return Field{Key: k} }\n"), + "zap/logger.go": "package zap\n\nfunc Info(msg string, fields []Field) {}\n", + } + for name, body in files.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(body, encoding="utf-8") + result = extract([tmp_path / n for n in files], root=tmp_path, + cache_root=tmp_path / "graphify-out", parallel=False) + fields = [n for n in result["nodes"] if n["label"] == "Field"] + assert [Path(n["source_file"]).as_posix().endswith("core/field.go") for n in fields] == [True] + label = _labels(result) + refs = {label.get(e["source"]) for e in result["edges"] + if e["relation"] == "references" and e["target"] == fields[0]["id"]} + assert {"String()", "Info()"} <= refs + + +@pytest.mark.parametrize("alias", [ + "type Field = core.Field[int]", # generic re-export: still the same type name + "type Field = core.Field", +]) +def test_a_re_export_alias_emits_no_node(tmp_path: Path, alias: str): + result = _extract_file(tmp_path, f"package zap\n\n{alias}\n") + assert [n for n in result["nodes"] if n["label"] == "Field"] == [] + + +@pytest.mark.parametrize("alias", [ + "type Field = core.Entry", # renamed: a new name, so a declaration here + "type Field = Entry", # an alias of a local type + "type Field = map[string]any", +]) +def test_an_alias_that_introduces_a_new_name_is_a_node(tmp_path: Path, alias: str): + result = _extract_file(tmp_path, f"package zap\n\n{alias}\n") + assert [n["label"] for n in result["nodes"] if n["label"] == "Field"] == ["Field"]