diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 443af97ce..f99ba5c82 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1345,41 +1345,79 @@ def _c_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str if c.is_named: _c_collect_type_refs(c, source, generic, out) -def _cpp_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: +def _cpp_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: + """Return C++ template type-parameter names visible from ``node``. + + A `template` binds `T`/`U` as local names, not corpus + symbols. Walking up to every enclosing `template_declaration`'s + `template_parameter_list` collects the names from its + `type_parameter_declaration`/`variadic_type_parameter_declaration` children. + A non-type parameter (`int N`) is a value, not a type, and is left alone. + """ + names: set[str] = set() + scope = node + while scope is not None: + for child in scope.children: + if child.type != "template_parameter_list": + continue + for param in child.children: + if param.type not in ( + "type_parameter_declaration", + "variadic_type_parameter_declaration", + ): + continue + for sub in param.children: + if sub.type == "type_identifier": + name = _read_text(sub, source) + if name: + names.add(name) + break + scope = scope.parent + return frozenset(names) + + +def _cpp_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]], + skip: frozenset[str] | None = None) -> None: """Walk a C++ type expression; append (name, role) tuples. Resolves qualified_identifier tails (std::string → string) and template_type - base + arguments (std::vector → vector + HttpClient as generic_arg).""" + base + arguments (std::vector → vector + HttpClient as generic_arg). + + Template type parameters in scope (``skip``) are local bindings, not + references; emitting one mints a sourceless stub (the bare `t` god node a + `template` class or function would otherwise share corpus-wide).""" if node is None or node.type in _C_PRIMITIVE_TYPE_NODES: return + if skip is None: + skip = _cpp_type_parameters_in_scope(node, source) t = node.type if t == "type_identifier": text = _read_text(node, source) - if text: + if text and text not in skip: out.append((text, "generic_arg" if generic else "type")) return if t == "qualified_identifier": name_node = node.child_by_field_name("name") if name_node is not None: - _cpp_collect_type_refs(name_node, source, generic, out) + _cpp_collect_type_refs(name_node, source, generic, out, skip) return if t == "template_type": name_node = node.child_by_field_name("name") if name_node is not None: text = _read_text(name_node, source) - if text: + if text and text not in skip: out.append((text, "generic_arg" if generic else "type")) args_node = node.child_by_field_name("arguments") if args_node is not None: for c in args_node.children: if c.is_named: - _cpp_collect_type_refs(c, source, True, out) + _cpp_collect_type_refs(c, source, True, out, skip) return if t in ("type_descriptor", "pointer_declarator", "reference_declarator", "array_declarator", "type_qualifier", "abstract_pointer_declarator", "abstract_reference_declarator", "abstract_array_declarator"): for c in node.children: if c.is_named: - _cpp_collect_type_refs(c, source, generic, out) + _cpp_collect_type_refs(c, source, generic, out, skip) def _scala_given_synthetic_name(node, source: bytes) -> str | None: """A stable label for an anonymous Scala 3 given (`given Show[Int] with`). diff --git a/tests/test_cpp_template_type_params.py b/tests/test_cpp_template_type_params.py new file mode 100644 index 000000000..fc1006422 --- /dev/null +++ b/tests/test_cpp_template_type_params.py @@ -0,0 +1,55 @@ +"""C++ template type parameters must not mint sourceless god nodes. + +A `template` binds `T` as a local name, not a corpus symbol. The +C++ type-ref pass appended the bare `T`/`K`/`V` identifiers used in a template +signature or member as type references and ran them through the sourceless-stub +path, so every template in the corpus collapsed its `T` onto one bare `t` node +— a god node (single-letter type params are not in the analyzer's builtin-noise +filter). C#, Java, TypeScript, Kotlin, Scala, and Go already exclude their type +parameters; C++ now matches. +""" +from pathlib import Path + +from graphify.extract import extract + + +def _sourceless(r): + return sorted({(n["id"], n.get("label")) for n in r["nodes"] if not n.get("source_file")}) + + +def test_class_and_function_template_params_mint_no_stub(tmp_path): + (tmp_path / "b.cpp").write_text( + "template\n" + "class Box {\n" + " T value;\n" + "public:\n" + " T get() { return value; }\n" + "};\n" + "template\n" + "V lookup(K key) { V v; return v; }\n" + ) + r = extract([tmp_path / "b.cpp"], cache_root=tmp_path, parallel=False) + assert _sourceless(r) == [] + assert not any(e.get("target") in ("t", "k", "v") for e in r["edges"]) + + +def test_non_type_param_and_real_types_unaffected(tmp_path): + # `N` is a non-type (value) template parameter, not a type; a genuine user + # type in the signature still references. + (tmp_path / "b.cpp").write_text( + "struct User { int id; };\n" + "template\n" + "class Buffer {\n" + " T data[N];\n" + "public:\n" + " User owner() { return User(); }\n" + "};\n" + ) + r = extract([tmp_path / "b.cpp"], cache_root=tmp_path, parallel=False) + assert not any(e.get("target") == "t" for e in r["edges"]) + refs = { + rn["label"] + for e in r["edges"] if e.get("relation") == "references" + for rn in r["nodes"] if rn["id"] == e["target"] + } + assert "User" in refs