diff --git a/graphify/extract.py b/graphify/extract.py index dea5bd0b3..e5ee1c3df 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3424,10 +3424,16 @@ def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: st if base_nid not in seen_ids: base_nid = _make_id(base) if base_nid not in seen_ids: + # origin_file lets _disambiguate_colliding_node_ids + # tell this file's unresolved reference apart from + # another file's same-named one, instead of every + # file's stub collapsing onto one shared bare id + # (see ensure_named_node(), which sets the same + # field for this exact reason). nodes.append({ "id": base_nid, "label": base, "file_type": "code", "source_file": "", - "source_location": "", + "source_location": "", "origin_file": str_path, }) seen_ids.add(base_nid) add_edge(class_nid, base_nid, "inherits", line) @@ -3681,18 +3687,7 @@ def walk(node, parent_class_nid: str | None = None) -> None: for arg in args.children: if arg.type == "identifier": base = _read_text(arg, 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) + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, "inherits", line) # Swift-specific: conformance / inheritance @@ -3831,18 +3826,7 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None: 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) + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, relation, line) for arg_child in user_type_node.children: if arg_child.type != "type_arguments": @@ -3876,18 +3860,7 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None: base = _read_text(consts[-1], source) break if base: - 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) + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, "inherits", line) # `include`/`extend`/`prepend ` in the class/module body -> @@ -4153,18 +4126,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: continue 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) + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, "inherits", line) # Emit a generic_arg reference for each type argument on the # base (Base -> Car references Dep). _cpp_collect_type_refs diff --git a/tests/test_extract.py b/tests/test_extract.py index 7cae30776..073ed1e7f 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -100,6 +100,37 @@ def test_extract_disambiguates_duplicate_symbol_ids_by_source_path(tmp_path): assert edge["target"] in node_ids, f"Dangling structural target: {edge}" +def test_cpp_unresolved_base_class_stubs_stay_disambiguated_by_file(tmp_path): + """Two different files' same-named, otherwise-undefined base class must not + collapse onto one shared stub node. + + The C++ base_class_clause handler used to build its stub inline instead of + calling ensure_named_node(), so it never tagged the stub with origin_file. + Without that tag, _disambiguate_colliding_node_ids couldn't tell file A's + reference to unresolved `Base` apart from file B's, and every file's + unresolved base class merged onto one bare id -- which could then collide + with an unrelated same-named real definition anywhere else in the corpus. + """ + first = tmp_path / "a" / "Foo.cpp" + second = tmp_path / "b" / "Bar.cpp" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + first.write_text("class Foo : public Base {};\n", encoding="utf-8") + second.write_text("class Bar : public Base {};\n", encoding="utf-8") + + result = extract([first, second], cache_root=tmp_path) + base_stubs = [ + node for node in result["nodes"] + if node["label"] == "Base" and not node.get("source_file") + ] + assert len(base_stubs) == 2 + assert len({node["id"] for node in base_stubs}) == 2 + + inherits_edges = [e for e in result["edges"] if e["relation"] == "inherits"] + assert len(inherits_edges) == 2 + assert len({e["target"] for e in inherits_edges}) == 2 + + def test_cross_file_type_annotation_refs_resolve_to_single_node(tmp_path): """#1402: a class defined once but referenced via type annotations in N other files must NOT create 1+N phantom duplicate nodes (with the referencing file's