diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index e0601cc..723606e 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2371,7 +2371,19 @@ def _extract_generic( metadata = {"is_nested_type": True} add_node(class_nid, class_name, line, metadata=metadata) callable_def_nids.add(class_nid) # a class is callable (constructor) - add_edge(file_nid, class_nid, "contains", line) + # A nested class/object/trait is contained by its ENCLOSING type, not + # the file (#2040). parent_class_nid is threaded down the walk for + # every language and is always a real class-like node (never a + # namespace — namespace handlers pass it through unchanged), so it is + # a valid edge source. The `!= class_nid` guard avoids a self-loop + # when same-name nesting (`class Foo: class Foo`) collides ids, since + # class ids omit the enclosing type name. Top-level types (parent + # None) still source from the file, keeping the containment tree + # connected: file -> Outer -> Inner. + if parent_class_nid and parent_class_nid != class_nid: + add_edge(parent_class_nid, class_nid, "contains", line) + else: + add_edge(file_nid, class_nid, "contains", line) # TS/JS decorators on the class and its members (@Component, @Injectable, # @Input, @Inject, @Entity, …). Decorators live only in class subtrees. diff --git a/tests/test_languages.py b/tests/test_languages.py index f36ea76..aff689d 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -553,6 +553,39 @@ def test_java_enum_and_annotation_declarations_are_type_nodes(tmp_path): assert definitions["Audited"].get("source_file") == str(source) +def test_nested_types_contained_by_enclosing_type(tmp_path): + """#2040: a nested class/object/trait's `contains` edge sources from its + ENCLOSING type, not the file node; top-level types still source from the file + (keeping the tree connected: file -> Outer -> Inner).""" + # Java inner class + j = tmp_path / "Outer.java" + j.write_text("class Outer {\n class Inner { void m() {} }\n}\n") + cj = _edge_labels(extract_java(j), "contains") + assert ("Outer.java", "Outer") in cj # top-level still file-sourced + assert ("Outer", "Inner") in cj # nested sourced from enclosing type + assert ("Outer.java", "Inner") not in cj # NOT from the file (the bug) + + # Scala nested class + object (the issue's repro shape) + s = tmp_path / "Outer.scala" + s.write_text("class Outer {\n class Inner\n object Obj\n}\n") + cs = _edge_labels(extract_scala(s), "contains") + assert ("Outer.scala", "Outer") in cs + assert ("Outer", "Inner") in cs + assert ("Outer", "Obj") in cs + assert ("Outer.scala", "Inner") not in cs + + +def test_csharp_nested_type_gets_containment_edge(tmp_path): + """#2040 for C#: the nested type now gets a real `contains` edge from its + enclosing type (the is_nested_type flag it already carried is retained and + covered by test_csharp_type_resolution).""" + c = tmp_path / "N.cs" + c.write_text("namespace N {\n class Outer {\n class Inner {}\n }\n}\n") + cc = _edge_labels(extract_csharp(c), "contains") + assert ("Outer", "Inner") in cc + assert ("N.cs", "Inner") not in cc + + def test_csharp_field_type_references_have_field_context(): r = extract_csharp(FIXTURES / "sample.cs") refs = _references(r)