fix(extract): tag inline base-class stubs with origin_file

ensure_named_node() tags the sourceless stub it creates for an
unresolved reference with origin_file, so _disambiguate_colliding_node_ids
can tell one file's unresolved reference apart from another's instead of
merging every file's same-named reference onto one shared bare id
(which can then collide with an unrelated same-named real definition
anywhere else in the corpus, since ids are case-normalized and global).

Five call sites duplicated that stub-creation logic inline instead of
calling ensure_named_node() -- Ruby's `Class.new(Super)` and
`class Foo < Base` inheritance, Python inheritance, Kotlin delegation
-specifier inheritance/conformance, and C++ base_class_clause
inheritance -- and none of them were updated when origin_file was added,
so all five still produce the un-disambiguated bare-id stub the fix was
meant to eliminate.

Four of the five sit directly inside _extract_generic, where
ensure_named_node() is already in scope as a closure, so they're
switched to call it directly. The fifth (Ruby's `Class.new(Super)`) is
handled by a separate helper, _ruby_extra_walk(), which doesn't have
that closure in scope; that one gets the same origin_file tag added
directly to its own inline stub dict, matching what ensure_named_node()
already does, without changing the helper's signature.

(A sixth occurrence of the same inline pattern exists in extract_apex(),
a fully separate regex-based extractor with no ensure_named_node()
equivalent of its own -- left out of this fix, which is scoped to the
shared _extract_generic path and its one directly-affiliated helper.)

Added a regression test: two different C++ files each inheriting from
the same undefined base class must produce two distinct stub nodes, not
one shared one. Fails on main (one shared 'base' id for both files),
passes with this fix.
This commit is contained in:
mallyskies
2026-07-08 01:21:55 +01:00
committed by safishamsi
parent bf7fa50f38
commit 9557bf6733
2 changed files with 42 additions and 49 deletions
+11 -49
View File
@@ -3450,10 +3450,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)
@@ -3707,18 +3713,7 @@ def _extract_generic(
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
@@ -3857,18 +3852,7 @@ def _extract_generic(
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":
@@ -3902,18 +3886,7 @@ def _extract_generic(
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 <Const>` in the class/module body ->
@@ -4179,18 +4152,7 @@ def _extract_generic(
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<Dep> -> Car references Dep). _cpp_collect_type_refs
+31
View File
@@ -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