Cross-language stub rewiring: unresolved base-class stub can bind to unrelated node in a different language
Summary
_rewire_unique_stub_nodes in extract.py can silently rewire an unresolved-symbol "stub" node (e.g. an inheritance target with no local definition) onto a completely unrelated node in a different language, as long as the label happens to match and there's exactly one other "real" candidate anywhere in the whole corpus. This produces false cross-language edges and can turn an unrelated variable into an artificial "god node" bridging communities that have nothing to do with each other.
Repro
repo/
backend/models.py
frontend/widget.test.tsx
backend/models.py:
from sqlalchemy.orm import declarative_base
Base = declarative_base() # not a `class` statement, so it's never registered as a "real" definition
class User(Base):
__tablename__ = "users"
frontend/widget.test.tsx:
const base = { id: 1, name: "test" };
test("renders", () => {
render(<Widget item={base} />);
});
Run:
from graphify.extract import extract
from pathlib import Path
result = extract([Path("backend/models.py"), Path("frontend/widget.test.tsx")], cache_root=Path("."))
print([e for e in result["edges"] if e["source"] == "backend_models_user" and e["relation"] == "inherits"])
Expected: an inherits edge from User to some Python-only placeholder for the unresolved Base symbol (or at minimum, not to a TS variable).
Actual:
[{'source': 'backend_models_user', 'target': 'frontend_widget_test_base', 'relation': 'inherits', ...}]
User now appears to "inherit" from a local test fixture object in a .tsx file. Extracting backend/models.py alone (without the .tsx file present) correctly produces target base (a bare, unresolved placeholder) — the bug only manifests when both files are extracted together, because _rewire_unique_stub_nodes runs once over the whole corpus.
On a real ~530-file mixed Python/TypeScript monolith, this exact pattern turned into a reported "god node" / cross-community bridge: ~90 unrelated SQLAlchemy ORM model classes all appeared to inherit from one frontend Jest/Vitest test's local const base = {...} fixture, purely because that fixture was the only other node in the whole corpus with the label base and Base (SQLAlchemy's declarative_base() return value) has no class statement anywhere for the extractor to resolve it against.
Root cause
extract.py, _rewire_unique_stub_nodes (around L7070 as of graphifyy 0.8.38):
def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None:
"""Map unresolved no-source stubs to a unique real definition with the same label."""
real_by_label: dict[str, list[dict]] = {}
stubs: list[dict] = []
for node in nodes:
key = _node_label_key(node)
if not key:
continue
if node.get("source_file"):
if _is_type_like_definition(node):
real_by_label.setdefault(key, []).append(node)
continue
stubs.append(node)
remap: dict[str, str] = {}
drop_ids: set[str] = set()
for stub in stubs:
stub_id = str(stub.get("id", ""))
if not stub_id:
continue
candidates = real_by_label.get(_node_label_key(stub), [])
if len(candidates) != 1:
continue
target_id = candidates[0].get("id")
if isinstance(target_id, str) and target_id and target_id != stub_id:
remap[stub_id] = target_id
drop_ids.add(stub_id)
...
Stub nodes (created e.g. around L2300-2320 for Python's class X(Base): when Base has no local class definition) carry source_file: "" and no language tag at all. The rewire step matches candidates purely by normalized label (_node_label_key) and _is_type_like_definition (which only checks the label doesn't look like a call/member access — it happily accepts a plain JS/TS const variable declaration). There is no check that the stub and the candidate come from the same language or even related file types. If exactly one same-labeled "real" node exists anywhere in the whole multi-language corpus, it wins by default, regardless of language.
This is easy to trigger in any polyglot repo where a common short identifier (Base, Client, Config, Handler, etc.) is:
- assigned via a factory call rather than a
class/interfacestatement in one language (so it never becomes a "real"_is_type_like_definitionnode), and - also happens to be used as a local variable/const name in a completely unrelated file in another language.
Suggested fix
Infer the stub's language from the edges that reference it (each edge carries source_file, i.e. the file that created the reference), and only allow rewiring onto a candidate from the same "extension family". JS/TS variants (.ts/.tsx/.js/.jsx/.mjs/.cjs) can stay grouped since they legitimately share one class hierarchy; everything else defaults to its own singleton family so unrelated languages never match:
_STUB_EXT_FAMILIES: dict[str, str] = {
".ts": "js", ".tsx": "js", ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js",
}
def _stub_ext_family(suffix: str) -> str:
return _STUB_EXT_FAMILIES.get(suffix.lower(), suffix.lower())
def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None:
"""Map unresolved no-source stubs to a unique real definition with the same label.
A stub carries no source_file of its own, so its language is inferred from
the edges that reference it (each edge's source_file is the file that
created the reference). Candidates are restricted to that same language
family — without this, an unresolved Python base class (e.g. `class
User(Base)` where `Base` isn't locally defined) can be rewired onto an
unrelated same-named symbol in a completely different language, such as a
local variable in a JS/TS test file.
"""
real_by_label: dict[str, list[dict]] = {}
stubs: list[dict] = []
for node in nodes:
key = _node_label_key(node)
if not key:
continue
if node.get("source_file"):
if _is_type_like_definition(node):
real_by_label.setdefault(key, []).append(node)
continue
stubs.append(node)
if not stubs:
return
stub_ids = {str(s.get("id", "")) for s in stubs if s.get("id")}
stub_origin_families: dict[str, set[str]] = {}
for edge in edges:
sf = edge.get("source_file")
if not sf:
continue
family = _stub_ext_family(Path(sf).suffix)
src, tgt = str(edge.get("source", "")), str(edge.get("target", ""))
if src in stub_ids:
stub_origin_families.setdefault(src, set()).add(family)
if tgt in stub_ids:
stub_origin_families.setdefault(tgt, set()).add(family)
remap: dict[str, str] = {}
drop_ids: set[str] = set()
for stub in stubs:
stub_id = str(stub.get("id", ""))
if not stub_id:
continue
candidates = real_by_label.get(_node_label_key(stub), [])
origin_families = stub_origin_families.get(stub_id)
if origin_families:
candidates = [
c for c in candidates
if _stub_ext_family(Path(c.get("source_file", "")).suffix) in origin_families
]
if len(candidates) != 1:
continue
target_id = candidates[0].get("id")
if isinstance(target_id, str) and target_id and target_id != stub_id:
remap[stub_id] = target_id
drop_ids.add(stub_id)
... # rest unchanged
I patched my local install with exactly this and confirmed it resolves the repro (re-running extract() on the two files above now correctly leaves User's inherits edge pointing at the bare, unresolved base placeholder instead of the .tsx test fixture), without affecting legitimate same-language stub resolution.
Happy to open a PR with this change plus a regression test if that's useful.
Environment
graphifyyversion: 0.8.38- Python 3.12
- Triggered via the AST-only (
--update, code-only fast path) extraction flow on a mixed Python/TypeScript monolith (~530 files)
Source: Graphify-Labs/graphify