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 <noreply@anthropic.com>
This commit is contained in:
madhusudanpatnaikandClaude Opus 4.8 authored and safishamsi committed 2026-10-23 18:15:20 +01:00
1 parent 291248ef49
commit 8cfda7f1ce
2 files changed
+199 -3

No files matched your search

+28 -3
View File
@@ -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)
+171
View File
@@ -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"]