feat: semantic type-reference edges for Swift, Kotlin, PHP, Rust, and Go (#1015)

This commit is contained in:
TheFedaikin
2026-05-28 14:46:03 +01:00
committed by GitHub
parent cddf47d3a0
commit 32aa053e6c
8 changed files with 1213 additions and 66 deletions
+835 -9
View File
@@ -667,6 +667,356 @@ def _java_method_annotation_names(method_node, source: bytes) -> list[str]:
return names
_GO_PREDECLARED_TYPES = frozenset({
"bool", "byte", "complex64", "complex128", "error", "float32", "float64",
"int", "int8", "int16", "int32", "int64", "rune", "string",
"uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "any", "comparable",
})
def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a Go type expression; append (name, role) tuples."""
if node is None:
return
t = node.type
if t == "type_identifier":
text = _read_text(node, source)
if text and text not in _GO_PREDECLARED_TYPES:
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:
out.append((text, "generic_arg" if generic else "type"))
return
if t == "generic_type":
type_field = node.child_by_field_name("type")
if type_field is not None:
sub: list[tuple[str, str]] = []
_go_collect_type_refs(type_field, source, generic, sub)
out.extend(sub)
for c in node.children:
if c.type == "type_arguments":
for arg in c.children:
if arg.is_named:
_go_collect_type_refs(arg, source, True, out)
return
if t in ("pointer_type", "slice_type", "array_type", "map_type",
"channel_type", "parenthesized_type"):
for c in node.children:
if c.is_named:
_go_collect_type_refs(c, source, generic, out)
return
if node.is_named:
for c in node.children:
if c.is_named:
_go_collect_type_refs(c, source, generic, out)
def _rust_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a Rust type expression; append (name, role) tuples."""
if node is None:
return
t = node.type
if t == "primitive_type":
return
if t == "type_identifier":
text = _read_text(node, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
return
if t == "scoped_type_identifier":
text = _read_text(node, source).rsplit("::", 1)[-1]
if text:
out.append((text, "generic_arg" if generic else "type"))
return
if t == "generic_type":
name_node = node.child_by_field_name("type")
if name_node is None:
for c in node.children:
if c.type in ("type_identifier", "scoped_type_identifier"):
name_node = c
break
if name_node is not None:
text = _read_text(name_node, source).rsplit("::", 1)[-1]
if text:
out.append((text, "generic_arg" if generic else "type"))
for c in node.children:
if c.type == "type_arguments":
for arg in c.children:
if arg.is_named:
_rust_collect_type_refs(arg, source, True, out)
return
if t in ("reference_type", "pointer_type", "array_type", "tuple_type", "slice_type"):
for c in node.children:
if c.is_named:
_rust_collect_type_refs(c, source, generic, out)
return
if node.is_named:
for c in node.children:
if c.is_named:
_rust_collect_type_refs(c, source, generic, out)
def _php_name_text(node, source: bytes) -> str | None:
"""Return the unqualified name text from a PHP `name`/`qualified_name` node."""
if node is None:
return None
return _read_text(node, source).rsplit("\\", 1)[-1] or None
def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a PHP type expression; append (name, role) tuples."""
if node is None:
return
t = node.type
if t == "primitive_type":
return
if t == "named_type":
for c in node.children:
if c.type in ("name", "qualified_name"):
text = _php_name_text(c, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
return
return
if t in ("name", "qualified_name"):
text = _php_name_text(node, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
return
if t in ("nullable_type", "union_type", "intersection_type", "optional_type"):
for c in node.children:
if c.is_named:
_php_collect_type_refs(c, source, generic, out)
return
if node.is_named:
for c in node.children:
if c.is_named:
_php_collect_type_refs(c, source, generic, out)
def _php_method_return_type_node(method_node):
"""Return the named_type/primitive_type node sitting after formal_parameters."""
saw_params = False
for c in method_node.children:
if c.type == "formal_parameters":
saw_params = True
continue
if saw_params and c.is_named and c.type not in ("compound_statement",):
if c.type in ("named_type", "primitive_type", "nullable_type",
"union_type", "intersection_type", "optional_type"):
return c
return None
def _kotlin_user_type_name(user_type_node, source: bytes) -> str | None:
"""Return the head identifier text from a Kotlin user_type node (without generics)."""
if user_type_node is None:
return None
for c in user_type_node.children:
if c.type == "type_identifier":
text = _read_text(c, source)
return text or None
if c.type == "identifier":
text = _read_text(c, source)
return text or None
if c.type == "simple_user_type":
for sub in c.children:
if sub.type in ("identifier", "type_identifier"):
text = _read_text(sub, source)
return text or None
return None
def _kotlin_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a Kotlin type expression; append (name, role) tuples."""
if node is None:
return
t = node.type
if t in ("integral_literal", "boolean_literal"):
return
if t == "user_type":
for c in node.children:
if c.type in ("identifier", "type_identifier"):
text = _read_text(c, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
break
if c.type == "simple_user_type":
for sub in c.children:
if sub.type in ("identifier", "type_identifier"):
text = _read_text(sub, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
break
break
for c in node.children:
if c.type == "type_arguments":
for arg in c.children:
if arg.type == "type_projection":
for sub in arg.children:
if sub.is_named:
_kotlin_collect_type_refs(sub, source, True, out)
elif arg.is_named:
_kotlin_collect_type_refs(arg, source, True, out)
return
if t in ("identifier", "type_identifier"):
text = _read_text(node, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
return
if t in ("nullable_type", "parenthesized_type", "type_reference"):
for c in node.children:
if c.is_named:
_kotlin_collect_type_refs(c, source, generic, out)
return
if node.is_named:
for c in node.children:
if c.is_named:
_kotlin_collect_type_refs(c, source, generic, out)
def _kotlin_property_type_node(property_node):
"""Find the user_type node within a Kotlin property_declaration."""
for c in property_node.children:
if c.type == "variable_declaration":
for sub in c.children:
if sub.type in ("user_type", "nullable_type", "type_reference"):
return sub
if c.type in ("user_type", "nullable_type", "type_reference"):
return c
return None
def _kotlin_function_return_type_node(func_node):
"""Find the return-type node of a Kotlin function_declaration (the type after `: ` post-params)."""
saw_params = False
saw_colon = False
for c in func_node.children:
if c.type == "function_value_parameters":
saw_params = True
continue
if saw_params and c.type == ":":
saw_colon = True
continue
if saw_colon:
if c.is_named:
return c
return None
def _swift_declaration_keyword(node) -> str | None:
"""Return the leading kind token for a Swift class_declaration: class/struct/enum/extension/actor."""
for c in node.children:
if not c.is_named and c.type in ("class", "struct", "enum", "extension", "actor"):
return c.type
return None
def _swift_pre_scan(root_node, source: bytes) -> tuple[set[str], set[str]]:
"""Pre-scan a Swift compilation unit and return (protocol_names, class_like_names)."""
protocols: set[str] = set()
classes: set[str] = set()
stack = [root_node]
while stack:
n = stack.pop()
if n.type == "protocol_declaration":
name_node = n.child_by_field_name("name")
if name_node is None:
for c in n.children:
if c.type == "type_identifier":
name_node = c
break
if name_node is not None:
text = _read_text(name_node, source)
if text:
protocols.add(text)
elif n.type == "class_declaration":
kw = _swift_declaration_keyword(n)
if kw in ("class", "struct", "enum", "actor"):
name_node = n.child_by_field_name("name")
if name_node is not None:
text = _read_text(name_node, source)
if text:
classes.add(text)
stack.extend(n.children)
return protocols, classes
def _swift_classify_base(name: str, kind: str | None, is_first: bool,
protocols: set[str], classes: set[str]) -> str:
"""Classify a Swift inheritance_specifier entry as `inherits` or `implements`."""
if name in protocols:
return "implements"
if name in classes:
return "inherits"
# struct/enum/extension/actor cannot inherit a class — all conformances are protocols.
if kind in ("struct", "enum", "extension", "actor"):
return "implements"
# `class`: first entry is conventionally the base class; subsequent are protocols.
return "inherits" if is_first else "implements"
def _swift_user_type_name(user_type_node, source: bytes) -> str | None:
"""Return the head type_identifier text from a Swift user_type node (without generics)."""
if user_type_node is None:
return None
for c in user_type_node.children:
if c.type == "type_identifier":
text = _read_text(c, source)
return text or None
return None
def _swift_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a Swift type expression; append (name, role) tuples (role 'type' or 'generic_arg')."""
if node is None:
return
t = node.type
if t == "type_annotation":
for c in node.children:
if c.is_named:
_swift_collect_type_refs(c, source, generic, out)
return
if t == "user_type":
for c in node.children:
if c.type == "type_identifier":
text = _read_text(c, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
break
for c in node.children:
if c.type == "type_arguments":
for arg in c.children:
if arg.is_named:
_swift_collect_type_refs(arg, source, True, out)
return
if t == "type_identifier":
text = _read_text(node, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
return
if t in ("optional_type", "implicitly_unwrapped_optional_type", "array_type",
"dictionary_type", "tuple_type"):
for c in node.children:
if c.is_named:
_swift_collect_type_refs(c, source, generic, out)
return
if node.is_named:
for c in node.children:
if c.is_named:
_swift_collect_type_refs(c, source, generic, out)
def _swift_property_type_node(property_node):
"""Return the type_annotation child of a Swift property_declaration, if any."""
for c in property_node.children:
if c.type == "type_annotation":
return c
return None
def _python_collect_param_refs(params_node, source: bytes) -> list[tuple[str, str]]:
"""Collect type refs from each typed parameter under a `parameters` node."""
out: list[tuple[str, str]] = []
@@ -1672,6 +2022,11 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
if config.ts_module == "tree_sitter_c_sharp":
csharp_interface_names = _csharp_pre_scan_interfaces(root, source)
swift_protocol_names: set[str] = set()
swift_class_names: set[str] = set()
if config.ts_module == "tree_sitter_swift":
swift_protocol_names, swift_class_names = _swift_pre_scan(root, source)
def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
@@ -1773,11 +2128,129 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
# Swift-specific: conformance / inheritance
if config.ts_module == "tree_sitter_swift":
swift_kind = _swift_declaration_keyword(node) if t == "class_declaration" else "protocol"
seen_swift_base = False
for child in node.children:
if child.type == "inheritance_specifier":
if child.type != "inheritance_specifier":
continue
base_name: str | None = None
user_type_node = None
for sub in child.children:
if sub.type in ("user_type", "type_identifier"):
base = _read_text(sub, source)
if sub.type == "user_type":
user_type_node = sub
base_name = _swift_user_type_name(sub, source)
break
if sub.type == "type_identifier":
base_name = _read_text(sub, source) or None
break
if not base_name:
continue
base_nid = _make_id(stem, base_name)
if base_nid not in seen_ids:
base_nid = _make_id(base_name)
if base_nid not in seen_ids:
nodes.append({
"id": base_nid,
"label": base_name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
seen_ids.add(base_nid)
if t == "protocol_declaration":
relation = "inherits"
else:
relation = _swift_classify_base(
base_name, swift_kind, not seen_swift_base,
swift_protocol_names, swift_class_names,
)
seen_swift_base = True
add_edge(class_nid, base_nid, relation, line)
if user_type_node is not None:
for arg_child in user_type_node.children:
if arg_child.type != "type_arguments":
continue
for arg in arg_child.children:
if not arg.is_named:
continue
refs: list[tuple[str, str]] = []
_swift_collect_type_refs(arg, source, True, refs)
for ref_name, _role in refs:
target = ensure_named_node(ref_name, line)
add_edge(class_nid, target, "references", line,
context="generic_arg")
# PHP-specific: extends → inherits, implements → implements, use → mixes_in
if config.ts_module == "tree_sitter_php":
def _php_emit_base(base_name: str, rel: str, at_line: int) -> None:
if not base_name:
return
base_nid = _make_id(stem, base_name)
if base_nid not in seen_ids:
base_nid = _make_id(base_name)
if base_nid not in seen_ids:
nodes.append({
"id": base_nid,
"label": base_name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, rel, at_line)
for child in node.children:
if child.type == "base_clause":
for sub in child.children:
if sub.type in ("name", "qualified_name"):
_php_emit_base(_php_name_text(sub, source) or "",
"inherits", child.start_point[0] + 1)
elif child.type == "class_interface_clause":
for sub in child.children:
if sub.type in ("name", "qualified_name"):
_php_emit_base(_php_name_text(sub, source) or "",
"implements", child.start_point[0] + 1)
body = node.child_by_field_name("body")
if body is None:
for c in node.children:
if c.type == "declaration_list":
body = c
break
if body is not None:
for member in body.children:
if member.type != "use_declaration":
continue
for sub in member.children:
if sub.type in ("name", "qualified_name"):
_php_emit_base(_php_name_text(sub, source) or "",
"mixes_in", member.start_point[0] + 1)
# Kotlin-specific: delegation_specifiers → inherits (constructor_invocation) / implements (user_type)
if config.ts_module == "tree_sitter_kotlin":
for child in node.children:
if child.type != "delegation_specifiers":
continue
for spec in child.children:
if spec.type != "delegation_specifier":
continue
relation = "implements"
user_type_node = None
for sub in spec.children:
if sub.type == "constructor_invocation":
relation = "inherits"
for inner in sub.children:
if inner.type == "user_type":
user_type_node = inner
break
break
if sub.type == "user_type":
user_type_node = sub
break
if user_type_node is None:
continue
base = _kotlin_user_type_name(user_type_node, source)
if not base:
continue
base_nid = _make_id(stem, base)
if base_nid not in seen_ids:
base_nid = _make_id(base)
@@ -1790,7 +2263,21 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
"source_location": "",
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
add_edge(class_nid, base_nid, relation, line)
for arg_child in user_type_node.children:
if arg_child.type != "type_arguments":
continue
for arg in arg_child.children:
if arg.type == "type_projection":
for inner in arg.children:
if not inner.is_named:
continue
refs: list[tuple[str, str]] = []
_kotlin_collect_type_refs(inner, source, True, refs)
for ref_name, _role in refs:
target = ensure_named_node(ref_name, line)
add_edge(class_nid, target, "references", line,
context="generic_arg")
# C#-specific: inheritance / interface implementation via base_list
if config.ts_module == "tree_sitter_c_sharp":
@@ -1939,6 +2426,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
if (t == "property_declaration"
and parent_class_nid
and config.event_listener_properties):
handled_event_listener = False
for element in node.children:
if element.type != "property_element":
continue
@@ -1956,6 +2444,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
or prop_name not in config.event_listener_properties
or array_node is None):
continue
handled_event_listener = True
for entry in array_node.children:
if entry.type != "array_element_initializer":
continue
@@ -1984,6 +2473,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
pending_listen_edges.append((event_cls, listener_cls, line_no))
break
break
if handled_event_listener:
return
if (config.ts_module == "tree_sitter_c_sharp"
@@ -2003,6 +2493,54 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
"references", line, context="field")
return
if (config.ts_module == "tree_sitter_php"
and t == "property_declaration"
and parent_class_nid):
for c in node.children:
if c.type not in ("named_type", "primitive_type", "nullable_type",
"union_type", "intersection_type", "optional_type"):
continue
line = node.start_point[0] + 1
refs: list[tuple[str, str]] = []
_php_collect_type_refs(c, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
target_nid = ensure_named_node(ref_name, line)
if target_nid != parent_class_nid:
add_edge(parent_class_nid, target_nid, "references", line, context=ctx)
break
return
if (config.ts_module == "tree_sitter_kotlin"
and t == "property_declaration"
and parent_class_nid):
type_node = _kotlin_property_type_node(node)
if type_node is not None:
line = node.start_point[0] + 1
refs: list[tuple[str, str]] = []
_kotlin_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
target_nid = ensure_named_node(ref_name, line)
if target_nid != parent_class_nid:
add_edge(parent_class_nid, target_nid, "references", line, context=ctx)
return
if (config.ts_module == "tree_sitter_swift"
and t == "property_declaration"
and parent_class_nid):
type_anno = _swift_property_type_node(node)
if type_anno is not None:
line = node.start_point[0] + 1
refs: list[tuple[str, str]] = []
_swift_collect_type_refs(type_anno, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
target_nid = ensure_named_node(ref_name, line)
if target_nid != parent_class_nid:
add_edge(parent_class_nid, target_nid, "references", line, context=ctx)
return
if (config.ts_module == "tree_sitter_cpp"
and t == "field_declaration"
and parent_class_nid):
@@ -2132,6 +2670,93 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context="attribute")
if config.ts_module == "tree_sitter_php":
params_container = None
for c in node.children:
if c.type == "formal_parameters":
params_container = c
break
if params_container is not None:
for p in params_container.children:
if p.type != "simple_parameter":
continue
type_node = None
for sub in p.children:
if sub.type in ("named_type", "primitive_type", "nullable_type",
"union_type", "intersection_type", "optional_type"):
type_node = sub
break
refs: list[tuple[str, str]] = []
_php_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
target_nid = ensure_named_node(ref_name, line)
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
return_node = _php_method_return_type_node(node)
if return_node is not None:
refs = []
_php_collect_type_refs(return_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
target_nid = ensure_named_node(ref_name, line)
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
if config.ts_module == "tree_sitter_kotlin":
params_container = None
for c in node.children:
if c.type == "function_value_parameters":
params_container = c
break
if params_container is not None:
for p in params_container.children:
if p.type != "parameter":
continue
param_type_node = None
for sub in p.children:
if sub.type in ("user_type", "nullable_type", "type_reference"):
param_type_node = sub
break
refs: list[tuple[str, str]] = []
_kotlin_collect_type_refs(param_type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
target_nid = ensure_named_node(ref_name, line)
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
return_type_node = _kotlin_function_return_type_node(node)
if return_type_node is not None:
refs = []
_kotlin_collect_type_refs(return_type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
target_nid = ensure_named_node(ref_name, line)
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
if config.ts_module == "tree_sitter_swift":
for p in node.children:
if p.type != "parameter":
continue
type_node = p.child_by_field_name("type")
refs: list[tuple[str, str]] = []
_swift_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
target_nid = ensure_named_node(ref_name, line)
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
return_node = node.child_by_field_name("return_type")
if return_node is not None:
refs = []
_swift_collect_type_refs(return_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
target_nid = ensure_named_node(ref_name, line)
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
body = _find_body(node, config)
if body:
function_bodies.append((func_nid, body))
@@ -4056,6 +4681,57 @@ def extract_go(path: Path) -> dict:
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(pkg_scope, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
return nid
def emit_go_method_refs(func_node, func_nid: str, line: int) -> None:
params = func_node.child_by_field_name("parameters")
if params is not None:
for p in params.children:
if p.type != "parameter_declaration":
continue
type_node = p.child_by_field_name("type")
refs: list[tuple[str, str]] = []
_go_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
result = func_node.child_by_field_name("result")
if result is not None:
if result.type == "parameter_list":
for p in result.children:
if p.type != "parameter_declaration":
continue
type_node = p.child_by_field_name("type")
if type_node is None:
for c in p.children:
if c.is_named:
type_node = c
break
refs = []
_go_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
else:
refs = []
_go_collect_type_refs(result, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
def walk(node) -> None:
t = node.type
@@ -4067,6 +4743,7 @@ def extract_go(path: Path) -> dict:
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
add_edge(file_nid, func_nid, "contains", line)
emit_go_method_refs(node, func_nid, line)
body = node.child_by_field_name("body")
if body:
function_bodies.append((func_nid, body))
@@ -4080,13 +4757,14 @@ def extract_go(path: Path) -> dict:
if param.type == "parameter_declaration":
type_node = param.child_by_field_name("type")
if type_node:
raw = _read_text(type_node, source).lstrip("*").strip()
receiver_type = raw
receiver_type = _read_text(type_node, source).lstrip("*").strip()
break
name_node = node.child_by_field_name("name")
if name_node:
if not name_node:
return
method_name = _read_text(name_node, source)
line = node.start_point[0] + 1
if receiver_type:
parent_nid = _make_id(pkg_scope, receiver_type)
add_node(parent_nid, receiver_type, line)
@@ -4097,6 +4775,8 @@ def extract_go(path: Path) -> dict:
method_nid = _make_id(stem, method_name)
add_node(method_nid, f"{method_name}()", line)
add_edge(file_nid, method_nid, "contains", line)
emit_go_method_refs(node, method_nid, line)
body = node.child_by_field_name("body")
if body:
function_bodies.append((method_nid, body))
@@ -4104,14 +4784,71 @@ def extract_go(path: Path) -> dict:
if t == "type_declaration":
for child in node.children:
if child.type == "type_spec":
if child.type != "type_spec":
continue
name_node = child.child_by_field_name("name")
if name_node:
if not name_node:
continue
type_name = _read_text(name_node, source)
line = child.start_point[0] + 1
type_nid = _make_id(pkg_scope, type_name)
add_node(type_nid, type_name, line)
add_edge(file_nid, type_nid, "contains", line)
# Type body: struct fields (with embeds) or interface embedding.
type_body = None
for tc in child.children:
if tc.type in ("struct_type", "interface_type"):
type_body = tc
break
if type_body is None:
continue
if type_body.type == "struct_type":
for fdl in type_body.children:
if fdl.type != "field_declaration_list":
continue
for field in fdl.children:
if field.type != "field_declaration":
continue
has_name = any(
fc.type == "field_identifier" for fc in field.children
)
type_node = field.child_by_field_name("type")
if type_node is None:
for fc in field.children:
if fc.is_named and fc.type != "field_identifier":
type_node = fc
break
refs: list[tuple[str, str]] = []
_go_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
tgt = ensure_named_node(ref_name, field.start_point[0] + 1)
if tgt == type_nid:
continue
if not has_name and role == "type":
add_edge(type_nid, tgt, "embeds",
field.start_point[0] + 1)
else:
ctx = "generic_arg" if role == "generic_arg" else "field"
add_edge(type_nid, tgt, "references",
field.start_point[0] + 1, context=ctx)
elif type_body.type == "interface_type":
for elem in type_body.children:
if elem.type != "type_elem":
continue
refs = []
for sub in elem.children:
if sub.is_named:
_go_collect_type_refs(sub, source, False, refs)
for ref_name, role in refs:
tgt = ensure_named_node(ref_name, elem.start_point[0] + 1)
if tgt == type_nid:
continue
if role == "type":
add_edge(type_nid, tgt, "embeds",
elem.start_point[0] + 1)
else:
add_edge(type_nid, tgt, "references",
elem.start_point[0] + 1, context="generic_arg")
return
if t == "import_declaration":
@@ -4284,6 +5021,39 @@ def extract_rust(path: Path) -> dict:
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
return nid
def emit_param_return_refs(func_node, func_nid: str, line: int) -> None:
params = func_node.child_by_field_name("parameters")
if params is not None:
for p in params.children:
if p.type != "parameter":
continue
type_node = p.child_by_field_name("type")
refs: list[tuple[str, str]] = []
_rust_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
return_type = func_node.child_by_field_name("return_type")
if return_type is not None:
refs = []
_rust_collect_type_refs(return_type, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
def walk(node, parent_impl_nid: str | None = None) -> None:
t = node.type
@@ -4300,6 +5070,7 @@ def extract_rust(path: Path) -> dict:
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
add_edge(file_nid, func_nid, "contains", line)
emit_param_return_refs(node, func_nid, line)
body = node.child_by_field_name("body")
if body:
function_bodies.append((func_nid, body))
@@ -4313,15 +5084,70 @@ def extract_rust(path: Path) -> dict:
item_nid = _make_id(stem, item_name)
add_node(item_nid, item_name, line)
add_edge(file_nid, item_nid, "contains", line)
if t == "trait_item":
for c in node.children:
if c.type != "trait_bounds":
continue
for sub in c.children:
if not sub.is_named:
continue
refs: list[tuple[str, str]] = []
_rust_collect_type_refs(sub, source, False, refs)
for idx, (ref_name, _role) in enumerate(refs):
tgt = ensure_named_node(ref_name, line)
if tgt == item_nid:
continue
rel = "inherits" if idx == 0 else "references"
if rel == "inherits":
add_edge(item_nid, tgt, "inherits", line)
else:
add_edge(item_nid, tgt, "references", line,
context="generic_arg")
if t == "struct_item":
for c in node.children:
if c.type != "field_declaration_list":
continue
for field in c.children:
if field.type != "field_declaration":
continue
type_node = field.child_by_field_name("type")
if type_node is None:
for fc in field.children:
if fc.type in ("type_identifier", "generic_type",
"scoped_type_identifier",
"reference_type", "primitive_type"):
type_node = fc
break
refs = []
_rust_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
tgt = ensure_named_node(ref_name, field.start_point[0] + 1)
if tgt != item_nid:
add_edge(item_nid, tgt, "references",
field.start_point[0] + 1, context=ctx)
return
if t == "impl_item":
type_node = node.child_by_field_name("type")
trait_node = node.child_by_field_name("trait")
impl_nid: str | None = None
if type_node:
type_name = _read_text(type_node, source).strip()
impl_nid = _make_id(stem, type_name)
add_node(impl_nid, type_name, node.start_point[0] + 1)
if trait_node is not None and impl_nid is not None:
refs: list[tuple[str, str]] = []
_rust_collect_type_refs(trait_node, source, False, refs)
for idx, (ref_name, _role) in enumerate(refs):
tgt = ensure_named_node(ref_name, node.start_point[0] + 1)
if tgt == impl_nid:
continue
if idx == 0:
add_edge(impl_nid, tgt, "implements", node.start_point[0] + 1)
else:
add_edge(impl_nid, tgt, "references", node.start_point[0] + 1,
context="generic_arg")
body = node.child_by_field_name("body")
if body:
for child in body.children:
+28
View File
@@ -21,6 +21,34 @@ func (s *Server) Stop() {
fmt.Println("stopped")
}
type Logger interface {
Log(msg string)
}
type Reader interface {
Read() string
}
type ReaderLogger interface {
Logger
Reader
}
type BaseProcessor struct{}
type Result struct {
value int
}
type DataProcessor struct {
BaseProcessor
current *Result
}
func (d *DataProcessor) Build(input *DataProcessor) (*Result, error) {
return nil, nil
}
func main() {
s := NewServer(8080)
s.Start()
+18
View File
@@ -17,6 +17,24 @@ class HttpClient(private val config: Config) {
}
}
interface Loggable {
fun log()
}
open class BaseProcessor
class Result<T>
class DataProcessor : BaseProcessor(), Loggable {
var current: Result<DataProcessor> = Result()
fun run(input: DataProcessor): Result<DataProcessor> {
return current
}
override fun log() {}
}
fun createClient(baseUrl: String): HttpClient {
val config = Config(baseUrl, 30)
return HttpClient(config)
+33
View File
@@ -33,6 +33,39 @@ class ApiClient
}
}
interface Loggable
{
public function log(): void;
}
trait HasName
{
public function getName(): string
{
return '';
}
}
class BaseProcessor {}
class Result {}
class DataProcessor extends BaseProcessor implements Loggable
{
use HasName;
private Result $current;
public function run(DataProcessor $input): Result
{
return new Result();
}
public function log(): void
{
}
}
function parseResponse(string $raw): array
{
return json_decode($raw, true);
+26
View File
@@ -25,3 +25,29 @@ fn build_graph(edges: Vec<(String, String)>) -> Graph {
}
g
}
trait Processor {
fn run(&self);
}
trait Logger: Processor {
fn log(&self);
}
struct Result<T> {
value: T,
}
struct DataProcessor {
current: Result<DataProcessor>,
}
impl Processor for DataProcessor {
fn run(&self) {}
}
impl DataProcessor {
fn build(input: DataProcessor) -> Result<DataProcessor> {
Result { value: input }
}
}
+10 -1
View File
@@ -9,8 +9,13 @@ protocol Loggable {
func log()
}
class DataProcessor: Processor {
class BaseProcessor {}
class Result<T> {}
class DataProcessor: BaseProcessor, Processor {
private var items: [String] = []
var current: Result<DataProcessor> = Result<DataProcessor>()
init() {}
@@ -24,6 +29,10 @@ class DataProcessor: Processor {
return validate(items)
}
func run(input: DataProcessor) -> Result<DataProcessor> {
return current
}
private func validate(_ data: [String]) -> [String] {
return data.filter { !$0.isEmpty }
}
+47 -22
View File
@@ -348,6 +348,20 @@ def test_kotlin_emits_in_file_calls():
assert ("createClient()", "HttpClient") in calls
def test_kotlin_splits_inherits_and_implements():
r = extract_kotlin(FIXTURES / "sample.kt")
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")
def test_kotlin_parameter_return_generic_and_field_contexts():
r = extract_kotlin(FIXTURES / "sample.kt")
assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
assert ("run", "Result") in _edge_labels(r, "references", "return_type")
assert ("run", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
# ── Scala ─────────────────────────────────────────────────────────────────────
def test_scala_no_error():
@@ -474,6 +488,20 @@ def test_php_event_listener_links_event_to_listener():
assert any("UserRegistered" in src and "SendWelcomeEmail" in tgt for src, tgt in listened)
def test_php_splits_inherits_implements_mixes_in():
r = extract_php(FIXTURES / "sample.php")
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")
assert ("DataProcessor", "HasName") in _edge_labels(r, "mixes_in")
def test_php_property_parameter_and_return_contexts():
r = extract_php(FIXTURES / "sample.php")
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
assert ("run", "Result") in _edge_labels(r, "references", "return_type")
# ── Swift ────────────────────────────────────────────────────────────────────
def test_swift_no_error():
@@ -568,31 +596,28 @@ def test_swift_extension_does_not_duplicate_type_node():
config_nodes = [n for n in r["nodes"] if n["label"] == "Config"]
assert len(config_nodes) == 1, f"Config should appear once, got {len(config_nodes)}"
def test_swift_conformance_edge():
def test_swift_protocol_conformance_emits_implements():
r = extract_swift(FIXTURES / "sample.swift")
inherits_edges = [e for e in r["edges"] if e["relation"] == "inherits"]
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
found = False
for e in inherits_edges:
src_label = node_by_id.get(e["source"], "")
tgt_label = node_by_id.get(e["target"], "")
if "DataProcessor" in src_label and "Processor" in tgt_label:
found = True
break
assert found, "DataProcessor should have inherits edge to Processor"
assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")
def test_swift_extension_conformance_edge():
def test_swift_extension_conformance_emits_implements():
r = extract_swift(FIXTURES / "sample.swift")
inherits_edges = [e for e in r["edges"] if e["relation"] == "inherits"]
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
found = False
for e in inherits_edges:
src_label = node_by_id.get(e["source"], "")
tgt_label = node_by_id.get(e["target"], "")
if "DataProcessor" in src_label and "Loggable" in tgt_label:
found = True
break
assert found, "extension should add conformance edge DataProcessor -> Loggable"
assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")
def test_swift_splits_inherits_and_implements():
r = extract_swift(FIXTURES / "sample.swift")
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")
def test_swift_parameter_return_generic_and_field_contexts():
r = extract_swift(FIXTURES / "sample.swift")
assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
assert ("run", "Result") in _edge_labels(r, "references", "return_type")
assert ("run", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
def test_swift_emits_calls():
r = extract_swift(FIXTURES / "sample.swift")
+182
View File
@@ -28,6 +28,22 @@ def _edges_with_relation(result, *relations):
return [e for e in result["edges"] if e["relation"] in relations]
def _normalize_symbol_label(label: str) -> str:
return label.strip("()").lstrip(".")
def _edge_labels(result, relation, context=None):
labels = {n["id"]: _normalize_symbol_label(n["label"]) for n in result["nodes"]}
pairs = set()
for e in result["edges"]:
if e.get("relation") != relation:
continue
if context is not None and e.get("context") != context:
continue
pairs.add((labels.get(e["source"], e["source"]), labels.get(e["target"], e["target"])))
return pairs
# ── TypeScript ────────────────────────────────────────────────────────────────
def test_ts_finds_class():
@@ -127,6 +143,149 @@ def test_go_no_dangling_edges():
assert e["source"] in node_ids
def test_go_embeds_struct_field():
r = extract_go(FIXTURES / "sample.go")
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "embeds")
def test_go_interface_embedding_emits_embeds():
r = extract_go(FIXTURES / "sample.go")
assert ("ReaderLogger", "Logger") in _edge_labels(r, "embeds")
def test_go_struct_named_field_emits_field_context():
r = extract_go(FIXTURES / "sample.go")
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
def test_go_method_parameter_return_contexts():
r = extract_go(FIXTURES / "sample.go")
assert ("Build", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
assert ("Build", "Result") in _edge_labels(r, "references", "return_type")
def test_go_method_declaration_emits_refs_only_when_name_present():
"""Regression: review feedback flagged a hypothetical UnboundLocalError in
extract_go's method_declaration branch if `name_node` were None. Statically
verify that every use of `method_nid` (and the `emit_go_method_refs` call
that consumes it) is guarded by a `name_node` truthiness check — either
nested inside `if name_node:` or following an early `if not name_node: return`.
Same for function_declaration and `func_nid`.
"""
import ast
import inspect
from graphify.extract import extract_go
tree = ast.parse(inspect.getsource(extract_go))
def _find_branch(root: ast.AST, type_literal: str) -> ast.If | None:
"""Return the `if t == '<type_literal>':` branch inside the walk function."""
for child in ast.walk(root):
if (isinstance(child, ast.If)
and isinstance(child.test, ast.Compare)
and isinstance(child.test.left, ast.Name)
and child.test.left.id == "t"
and len(child.test.comparators) == 1
and isinstance(child.test.comparators[0], ast.Constant)
and child.test.comparators[0].value == type_literal):
return child
return None
method_branch = _find_branch(tree, "method_declaration")
function_branch = _find_branch(tree, "function_declaration")
assert method_branch is not None, "method_declaration branch not found in extract_go"
assert function_branch is not None, "function_declaration branch not found in extract_go"
def _is_early_return_on_falsy_name_node(stmt: ast.AST) -> bool:
"""True iff `stmt` is `if not name_node: return` (or raise/continue/break)."""
if not isinstance(stmt, ast.If):
return False
test = stmt.test
is_falsy_check = (
isinstance(test, ast.UnaryOp)
and isinstance(test.op, ast.Not)
and isinstance(test.operand, ast.Name)
and test.operand.id == "name_node"
)
if not is_falsy_check:
return False
terminators = (ast.Return, ast.Raise, ast.Continue, ast.Break)
return any(isinstance(s, terminators) for s in stmt.body)
def _guarded_by_name_node(branch: ast.If, var_name: str) -> bool:
"""True iff every read of `var_name` in `branch` is guarded by a
`name_node` truthiness check — either lexically nested under
`if name_node:` or after a preceding `if not name_node: return`."""
parents: dict[int, ast.AST] = {}
for parent in ast.walk(branch):
for child in ast.iter_child_nodes(parent):
parents[id(child)] = parent
def _stmt_chain(start: ast.AST) -> list[tuple[ast.stmt, list[ast.stmt]]]:
"""Walk up to each enclosing statement-list, returning (stmt, siblings)."""
chain: list[tuple[ast.stmt, list[ast.stmt]]] = []
cur: ast.AST | None = start
while cur is not None:
parent = parents.get(id(cur))
if parent is None:
break
if isinstance(cur, ast.stmt):
for attr in ("body", "orelse", "finalbody"):
siblings = getattr(parent, attr, None)
if isinstance(siblings, list) and cur in siblings:
chain.append((cur, siblings))
break
cur = parent
return chain
def _is_guarded(use: ast.AST) -> bool:
for stmt, siblings in _stmt_chain(use):
parent = parents.get(id(stmt))
# Case 1: lexically nested under `if name_node:` body
if (isinstance(parent, ast.If)
and isinstance(parent.test, ast.Name)
and parent.test.id == "name_node"
and stmt in parent.body):
return True
# Case 2: a preceding sibling is `if not name_node: return`
idx = siblings.index(stmt)
if any(_is_early_return_on_falsy_name_node(s) for s in siblings[:idx]):
return True
return False
for node in ast.walk(branch):
if isinstance(node, ast.Name) and node.id == var_name:
if not _is_guarded(node):
return False
return True
assert _guarded_by_name_node(method_branch, "method_nid"), (
"method_nid use is not guarded by a name_node check in method_declaration branch"
)
assert _guarded_by_name_node(function_branch, "func_nid"), (
"func_nid use is not guarded by a name_node check in function_declaration branch"
)
# Negative control: confirm the checker would actually reject the buggy
# layout the reviewer described. A `method_nid` reference dangling without
# any name_node guard must be caught.
bad_source = (
"def walk(node):\n"
" if t == 'method_declaration':\n"
" name_node = node.child_by_field_name('name')\n"
" if name_node:\n"
" method_nid = make_id('x')\n"
" emit_go_method_refs(node, method_nid, 1)\n"
" return\n"
)
bad_tree = ast.parse(bad_source)
bad_branch = _find_branch(bad_tree, "method_declaration")
assert bad_branch is not None
assert not _guarded_by_name_node(bad_branch, "method_nid"), (
"checker should reject method_nid used without a name_node guard"
)
# ── Rust ──────────────────────────────────────────────────────────────────────
def test_rust_finds_struct():
@@ -177,6 +336,29 @@ def test_rust_no_dangling_edges():
assert e["source"] in node_ids
def test_rust_trait_impl_emits_implements():
r = extract_rust(FIXTURES / "sample.rs")
assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")
def test_rust_supertrait_emits_inherits():
r = extract_rust(FIXTURES / "sample.rs")
assert ("Logger", "Processor") in _edge_labels(r, "inherits")
def test_rust_struct_field_emits_field_context():
r = extract_rust(FIXTURES / "sample.rs")
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
assert ("DataProcessor", "DataProcessor") not in _edge_labels(r, "references", "field")
def test_rust_method_parameter_return_and_generic_contexts():
r = extract_rust(FIXTURES / "sample.rs")
assert ("build", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
assert ("build", "Result") in _edge_labels(r, "references", "return_type")
assert ("build", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
def test_rust_no_cross_crate_spurious_edges():
"""Scoped calls (Type::method) and blocklisted names must not produce
INFERRED cross-crate calls edges (#908)."""