From 3bab26798ee71d466d51ed906f3294d627777784 Mon Sep 17 00:00:00 2001 From: madhusudanpatnaik Date: Fri, 23 Oct 2026 18:15:21 +0100 Subject: [PATCH] fix(cpp): extract namespace-scope `using`/`typedef` aliases as type nodes (#4580) Applied from PR #4580 (authorship preserved); stale cache-schema bump dropped for one combined bump. Co-Authored-By: Claude Opus 4.8 --- graphify/extractors/engine.py | 79 ++++++++++++++ tests/test_cpp_nested_and_cli.py | 17 ++- tests/test_cpp_type_alias.py | 174 +++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 tests/test_cpp_type_alias.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 6e87e70a0..c9afec974 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4848,6 +4848,70 @@ def _scala_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: s return True return False +def _cpp_declarator_type_name(declarator, source: bytes) -> str | None: + """The name a typedef declarator introduces: `Handle`, `*HandlePtr`, + `(*Fn)(int)`, `Arr[4]` each wrap one `type_identifier`.""" + node = declarator + while node is not None and node.type != "type_identifier": + inner = node.child_by_field_name("declarator") + if inner is None and node.type == "parenthesized_declarator": + inner = next(iter(node.named_children), None) + node = inner + return _read_text(node, source) if node is not None else None + + +def _cpp_type_last_name(type_node, source: bytes) -> str | None: + """`Bar` for `ns::Bar` or `ns::Bar`: the name a qualified type ends in.""" + if type_node is not None and type_node.type == "type_descriptor": + type_node = type_node.child_by_field_name("type") + if type_node is None or type_node.type != "qualified_identifier": + return None + while type_node is not None and type_node.type == "qualified_identifier": + type_node = type_node.child_by_field_name("name") + if type_node is not None and type_node.type == "template_type": + type_node = type_node.child_by_field_name("name") + return _read_text(type_node, source) if type_node is not None else None + + +def _cpp_alias_names(node, source: bytes) -> list[str]: + """The type names a namespace- or file-scope `using X = ...;` or `typedef` + declares. A member alias (`using value_type = T;` in a class) is not one: it + sits in every container and C++ member ids are not class-qualified, so they + would fold into one hub. Neither is the struct a C-style + `typedef struct Foo {...} Foo;` names (the struct's own node is it), nor a + same-name re-export (`using Bar = ns::Bar;`), which denotes the original + type and declares no new name. An include guard or `#if`/`#ifdef` block is + a conditional wrapper, not a scope, so an alias inside one keeps the scope + around it.""" + scope = node.parent + while scope is not None and ( + scope.type == "template_declaration" or scope.type.startswith("preproc_")): + scope = scope.parent + if scope is None or scope.type not in ("translation_unit", "declaration_list"): + return [] + target = node.child_by_field_name("type") + if node.type == "alias_declaration": + name_node = node.child_by_field_name("name") + candidates = [_read_text(name_node, source)] if name_node is not None else [] + else: + candidates = [_cpp_declarator_type_name(d, source) + for d in node.children_by_field_name("declarator")] + own_names = {_cpp_type_last_name(target, source)} + if target is not None and target.type in ( + "struct_specifier", "class_specifier", "union_specifier", "enum_specifier"): + spec_name = target.child_by_field_name("name") + if spec_name is not None: + own_names.add(_read_text(spec_name, source)) + # A set, not `in names`: one typedef can declare thousands of names. + names: list[str] = [] + seen = set(own_names) + for name in candidates: + if name and name not in seen: + seen.add(name) + names.append(name) + return names + + def _cpp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn) -> bool: @@ -4858,7 +4922,22 @@ def _cpp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str `enumerator` node (`Red`, `Green = 2`) whose name is its `name` field (an `identifier`). Without this the enum type is a memberless leaf. This is the C++ parity of Java #1719 (enum_constant), Swift, and Scala. + + A namespace- or file-scope alias (`using X = ...;`, `typedef ... X;`) also + gets a type node contained by the file, so a type reference to it lands on + a sourced declaration instead of a sourceless stub. Returns False for it: + the walk goes on, so a typedef'd struct body still gets its own node. + C++ never populates namespace_stack, so the id is the one + ensure_named_node looks up for a name declared in this file. """ + if node.type in ("alias_declaration", "type_definition"): + line = node.start_point[0] + 1 + for alias_name in _cpp_alias_names(node, source): + alias_nid = _make_id(stem, alias_name) + if alias_nid not in seen_ids: + add_node_fn(alias_nid, alias_name, line) + add_edge_fn(file_nid, alias_nid, "contains", line) + return False if node.type == "enumerator" and parent_class_nid: name_node = node.child_by_field_name("name") if name_node is None: diff --git a/tests/test_cpp_nested_and_cli.py b/tests/test_cpp_nested_and_cli.py index b9668b24d..adde50b23 100644 --- a/tests/test_cpp_nested_and_cli.py +++ b/tests/test_cpp_nested_and_cli.py @@ -42,8 +42,15 @@ def test_cpp_enum_specifier_members_are_extracted(tmp_path): assert ("Old", "Y") in case_of # An anonymous enum (`typedef enum { A, B } Flag`) has no type name, so it is # skipped rather than emitting a nameless node; its members do not appear. + # The typedef name `Flag` is a declared type of its own (an alias node, with + # no members of its own) since namespace-scope typedefs are extracted. assert "A" not in labels - assert "Flag" not in labels + assert "" not in labels + assert ("colors.hpp", "Flag") in { + (ids.get(e["source"]), ids.get(e["target"])) + for e in result["edges"] if e["relation"] == "contains" + } + assert not any(src == "Flag" for src, _ in case_of) def test_cpp_enum_nested_in_class_and_namespace_is_extracted(tmp_path): @@ -119,8 +126,12 @@ def test_cpp_union_specifier_is_extracted(tmp_path): assert ("Value", "i") in defines assert ("Value", "f") in defines # An anonymous union (`typedef union { ... } Anon`) has no type name, so it is - # skipped rather than emitting a nameless node, like an anonymous enum. - assert "Anon" not in labels + # skipped rather than emitting a nameless node, like an anonymous enum. The + # typedef name `Anon` is an alias node of its own, owning none of the members. + assert "" not in labels + assert ("value.hpp", "Anon") in contains + assert not any(src == "Anon" for src, _ in defines) + assert not {"a", "b"} & labels def test_cpp_union_member_function_is_a_method(tmp_path): diff --git a/tests/test_cpp_type_alias.py b/tests/test_cpp_type_alias.py new file mode 100644 index 000000000..7d7143f35 --- /dev/null +++ b/tests/test_cpp_type_alias.py @@ -0,0 +1,174 @@ +"""C++ `using X = ...;` and `typedef ... X;` at namespace scope are declarations. + +The C++ extractor emitted no node for an alias declaration or a typedef, so a +field, parameter or return typed with one, even in the declaring header, +resolved to a SOURCELESS stub that the corpus rewire can grow into a hub. A +namespace- or file-scope alias is now a type node contained by its file; every +declarator of a typedef counts (`typedef int Handle, *HandlePtr;`, +`typedef int (*Fn)(int);`, `typedef int Arr[4];`), and an alias template +(`template using Ptr = T*;`) too. An include guard or an +`#if`/`#ifdef` block is a conditional wrapper, not a scope: an alias inside +one at file scope is still file-scope. + +Not declarations here: a member alias (`using value_type = T;` in a class: one +in every container, and C++ class-member ids are not class-qualified, so they +would fold into one hub); the struct a C-style `typedef struct Foo {...} Foo;` +names (the struct's own node is it); and a same-name re-export +(`using Bar = ns::Bar;`), which denotes the original type and declares no new +name. +""" +from __future__ import annotations + +import importlib.util +import textwrap +from pathlib import Path + +import pytest + +from graphify.extract import extract + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("tree_sitter_cpp") is None, + reason="tree_sitter_cpp not installed", +) + +_HEADER = textwrap.dedent("""\ + #include + #include + namespace app { + using IntVec = std::vector; + typedef int Handle, *HandlePtr; + typedef struct { int x; } Point; + typedef int (*Fn)(int); + template using Ptr = T*; + using Callback = std::function; + struct Widget { + using value_type = int; + typedef int size_type; + IntVec items; + Handle h; + Point p; + Fn f; + Ptr raw; + Callback cb; + IntVec get(Handle h, Callback cb); + }; + } + """) + + +def _extract(tmp_path: Path, files: dict[str, str]) -> dict: + for name, body in files.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(body, encoding="utf-8") + return extract([tmp_path / n for n in files], root=tmp_path, + cache_root=tmp_path / "graphify-out", parallel=False) + + +def _labels(result: dict) -> dict[str, str]: + return {n["id"]: n["label"] for n in result["nodes"]} + + +def _pairs(result: dict, relation: str) -> set[tuple[str, str]]: + label = _labels(result) + return {(label.get(e["source"], str(e["source"])), label.get(e["target"], str(e["target"]))) + for e in result["edges"] if e["relation"] == relation} + + +def test_each_namespace_scope_alias_is_a_sourced_node_on_its_line(tmp_path: Path): + result = _extract(tmp_path, {"widget.hpp": _HEADER}) + expected = {"IntVec": "L4", "Handle": "L5", "HandlePtr": "L5", "Point": "L6", + "Fn": "L7", "Ptr": "L8", "Callback": "L9"} + for label, line in expected.items(): + matches = [n for n in result["nodes"] if n["label"] == label] + assert len(matches) == 1, label + assert matches[0]["source_file"], label + assert matches[0]["source_location"] == line, label + assert [n["label"] for n in result["nodes"] if not n.get("source_file")] == [] + + +def test_each_alias_is_contained_by_its_file(tmp_path: Path): + contained = {tgt for _src, tgt in _pairs(_extract(tmp_path, {"widget.hpp": _HEADER}), "contains")} + assert {"IntVec", "Handle", "HandlePtr", "Point", "Fn", "Ptr", "Callback"} <= contained + + +def test_fields_reference_the_local_aliases(tmp_path: Path): + refs = _pairs(_extract(tmp_path, {"widget.hpp": _HEADER}), "references") + assert {("Widget", "IntVec"), ("Widget", "Handle"), ("Widget", "Point"), ("Widget", "Fn"), + ("Widget", "Ptr"), ("Widget", "Callback")} <= refs + + +def test_member_aliases_are_not_file_level_nodes(tmp_path: Path): + result = _extract(tmp_path, {"widget.hpp": _HEADER}) + assert [n for n in result["nodes"] if n["label"] in ("value_type", "size_type")] == [] + + +def test_a_c_style_typedef_of_a_named_struct_is_the_struct(tmp_path: Path): + result = _extract(tmp_path, {"foo.hpp": "typedef struct Foo { int y; } Foo;\nstruct User { Foo f; };\n"}) + foos = [n for n in result["nodes"] if n["label"] == "Foo"] + assert len(foos) == 1 and foos[0]["source_file"] + assert ("User", "Foo") in _pairs(result, "references") + + +def test_a_same_name_re_export_emits_no_node(tmp_path: Path): + result = _extract(tmp_path, {"api.hpp": "namespace ns { struct Bar {}; }\nusing Bar = ns::Bar;\n"}) + bars = [n for n in result["nodes"] if n["label"] == "Bar"] + assert len(bars) == 1 + assert bars[0]["source_location"] == "L1" + + +def test_a_reference_from_another_file_lands_on_the_alias(tmp_path: Path): + result = _extract(tmp_path, { + "types.hpp": "#include \nusing IntVec = std::vector;\n", + "user.hpp": '#include "types.hpp"\nstruct Bag { IntVec v; };\n', + }) + [alias] = [n for n in result["nodes"] if n["label"] == "IntVec"] + assert alias["source_file"].endswith("types.hpp") + label = _labels(result) + targets = {e["target"] for e in result["edges"] + if e["relation"] == "references" and label.get(e["source"]) == "Bag"} + assert targets == {alias["id"]} + + +_GUARDED = textwrap.dedent("""\ + #ifndef CONFIG_HPP + #define CONFIG_HPP + using Guarded = int; + typedef long GuardedT; + #if defined(USE_WIDE) + using Char = wchar_t; + #elif defined(USE_U16) + using Char16 = char16_t; + #else + using Narrow = char; + #endif + #ifdef HAVE_POOL + template using Pool = T*; + #endif + struct Config { + #ifdef DEBUG + using debug_type = int; + #endif + Guarded g; + GuardedT t; + Narrow n; + }; + #endif + """) + + +def test_aliases_inside_include_guards_and_conditionals_are_nodes(tmp_path: Path): + result = _extract(tmp_path, {"config.hpp": _GUARDED}) + expected = {"Guarded": "L3", "GuardedT": "L4", "Char": "L6", "Char16": "L8", + "Narrow": "L10", "Pool": "L13"} + for label, line in expected.items(): + matches = [n for n in result["nodes"] if n["label"] == label] + assert len(matches) == 1, label + assert matches[0]["source_file"], label + assert matches[0]["source_location"] == line, label + assert {("Config", "Guarded"), ("Config", "GuardedT"), ("Config", "Narrow")} <= _pairs(result, "references") + + +def test_a_member_alias_inside_a_conditional_is_still_a_member(tmp_path: Path): + result = _extract(tmp_path, {"config.hpp": _GUARDED}) + assert [n for n in result["nodes"] if n["label"] == "debug_type"] == []