From 32aa053e6ca1fdfe5afb1460fa955c1a9178852b Mon Sep 17 00:00:00 2001 From: TheFedaikin Date: Thu, 28 May 2026 16:46:03 +0300 Subject: [PATCH] feat: semantic type-reference edges for Swift, Kotlin, PHP, Rust, and Go (#1015) --- graphify/extract.py | 912 ++++++++++++++++++++++++++++++++++-- tests/fixtures/sample.go | 28 ++ tests/fixtures/sample.kt | 18 + tests/fixtures/sample.php | 33 ++ tests/fixtures/sample.rs | 26 + tests/fixtures/sample.swift | 11 +- tests/test_languages.py | 69 ++- tests/test_multilang.py | 182 +++++++ 8 files changed, 1213 insertions(+), 66 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index c2443f1b..20e697e9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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,24 +2128,156 @@ 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 == "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 ("user_type", "type_identifier"): - base = _read_text(sub, source) - base_nid = _make_id(stem, base) - if base_nid not in seen_ids: - base_nid = _make_id(base) - if base_nid not in seen_ids: - nodes.append({ - "id": base_nid, - "label": base, - "file_type": "code", - "source_file": "", - "source_location": "", - }) - seen_ids.add(base_nid) - add_edge(class_nid, base_nid, "inherits", line) + 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) + if base_nid not in seen_ids: + nodes.append({ + "id": base_nid, + "label": base, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + 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,7 +2473,8 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: pending_listen_edges.append((event_cls, listener_cls, line_no)) break break - return + if handled_event_listener: + return if (config.ts_module == "tree_sitter_c_sharp" and t == "field_declaration" @@ -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,38 +4757,98 @@ 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: - 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) - method_nid = _make_id(parent_nid, method_name) - add_node(method_nid, f".{method_name}()", line) - add_edge(parent_nid, method_nid, "method", line) - else: - method_nid = _make_id(stem, method_name) - add_node(method_nid, f"{method_name}()", line) - add_edge(file_nid, method_nid, "contains", line) - body = node.child_by_field_name("body") - if body: - function_bodies.append((method_nid, body)) + 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) + method_nid = _make_id(parent_nid, method_name) + add_node(method_nid, f".{method_name}()", line) + add_edge(parent_nid, method_nid, "method", line) + else: + 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)) return if t == "type_declaration": for child in node.children: - if child.type == "type_spec": - name_node = child.child_by_field_name("name") - if name_node: - 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) + if child.type != "type_spec": + continue + name_node = child.child_by_field_name("name") + 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: diff --git a/tests/fixtures/sample.go b/tests/fixtures/sample.go index 073a9273..728aa35c 100644 --- a/tests/fixtures/sample.go +++ b/tests/fixtures/sample.go @@ -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() diff --git a/tests/fixtures/sample.kt b/tests/fixtures/sample.kt index 0f628463..5c4489c5 100644 --- a/tests/fixtures/sample.kt +++ b/tests/fixtures/sample.kt @@ -17,6 +17,24 @@ class HttpClient(private val config: Config) { } } +interface Loggable { + fun log() +} + +open class BaseProcessor + +class Result + +class DataProcessor : BaseProcessor(), Loggable { + var current: Result = Result() + + fun run(input: DataProcessor): Result { + return current + } + + override fun log() {} +} + fun createClient(baseUrl: String): HttpClient { val config = Config(baseUrl, 30) return HttpClient(config) diff --git a/tests/fixtures/sample.php b/tests/fixtures/sample.php index 636d49f8..1397f563 100644 --- a/tests/fixtures/sample.php +++ b/tests/fixtures/sample.php @@ -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); diff --git a/tests/fixtures/sample.rs b/tests/fixtures/sample.rs index 4981ca66..16e7fac2 100644 --- a/tests/fixtures/sample.rs +++ b/tests/fixtures/sample.rs @@ -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 { + value: T, +} + +struct DataProcessor { + current: Result, +} + +impl Processor for DataProcessor { + fn run(&self) {} +} + +impl DataProcessor { + fn build(input: DataProcessor) -> Result { + Result { value: input } + } +} diff --git a/tests/fixtures/sample.swift b/tests/fixtures/sample.swift index 648bb6d4..0a51d2fa 100644 --- a/tests/fixtures/sample.swift +++ b/tests/fixtures/sample.swift @@ -9,8 +9,13 @@ protocol Loggable { func log() } -class DataProcessor: Processor { +class BaseProcessor {} + +class Result {} + +class DataProcessor: BaseProcessor, Processor { private var items: [String] = [] + var current: Result = Result() init() {} @@ -24,6 +29,10 @@ class DataProcessor: Processor { return validate(items) } + func run(input: DataProcessor) -> Result { + return current + } + private func validate(_ data: [String]) -> [String] { return data.filter { !$0.isEmpty } } diff --git a/tests/test_languages.py b/tests/test_languages.py index 1a85bde4..65fbf5a7 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -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") diff --git a/tests/test_multilang.py b/tests/test_multilang.py index a0e39c2d..c30b9e10 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -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 == '':` 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)."""