#2207·graphify

Cross-language stub rewiring: unresolved base-class stub can bind to unrelated node in a different language

Author: nhan303iCreated Jul 26, 2026Updated Sep 16, 2026

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:

  1. assigned via a factory call rather than a class/interface statement in one language (so it never becomes a "real" _is_type_like_definition node), and
  2. 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

  • graphifyy version: 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)