Skip to content

Interop guard missing on the type-match branch of stub resolution: Swift/Python Path binds to a JS class #2343

Description

@alesdev88

Summary

The cross-language interop guard added in #1781 / #1718 / #1749 is applied only to the unique-function fallback in _absorb_stubs. The two branches above it — unique type/class match, and its case-insensitive variant — bind with no family check at all.

Because most name collisions across languages are on types rather than functions (Path, Shape, Task, Timer, Color, Node, Point, Rect), the guard misses the common case. A Swift Path and a Python pathlib.Path both bind to a JavaScript class Path when that JS class is the only definition with that label in the corpus.

Version: graphify 0.9.31 (graphifyy[mcp], uv tool install), Python 3.11, macOS.

Reproduction

Three files in one directory:

// three.js
class Path {
  constructor() { this.curves = []; }
  moveTo(x, y) { return this; }
}
export { Path };
// Shape.swift
import SwiftUI

struct Crosshair {
    let outline: Path

    func makeOutline() -> Path {
        return Path()
    }
}
# build.py
from pathlib import Path


def resolve_root(base: Path) -> Path:
    return base.parent
from graphify.extract import extract
from pathlib import Path as P

r = extract([P('three.js'), P('Shape.swift'), P('build.py')], cache_root=P('.'))

Actual

Five cross-language edges, all binding to the JS class:

[references] Crosshair      (swift)  -> Path (js)
[references] .makeOutline() (swift)  -> Path (js)
[calls]      .makeOutline() (swift)  -> Path (js)
[references] resolve_root() (python) -> Path (js)
[references] resolve_root() (python) -> Path (js)

Note build.py has an explicit from pathlib import Path, and graphify even emits the imports_from build -> pathlib edge — the binding overrides an unambiguous same-language import.

Expected

Three distinct nodes: the real JS class, plus one unresolved stub per foreign language. Swift does not call a JS class by name, and Python does not either.

Root cause

In graphify/extract.py, _absorb_stubs (around L1941–1966 in 0.9.31):

candidates = real_by_label.get(_node_label_key(stub), [])          # <-- no family gate
if len(candidates) != 1:
    candidates = real_by_label_ci.get(_node_label_key(stub, True), [])  # <-- no family gate
if len(candidates) != 1:
    fcands = func_by_label.get(_node_label_key(stub), [])
    if len(fcands) == 1 and stub_id not in supertype_stub_ids:
        fams = stub_families.get(stub_id, set())
        cand_fam = _lang_family(fcands[0].get("source_file"))
        if not fams or cand_fam is None or cand_fam in fams:       # <-- gate lives here only
            candidates = fcands
if len(candidates) != 1:
    continue
target_id = candidates[0].get("id")

stub_families and _lang_family are already computed for every stub, so the data needed for the check is present on all three branches — it is simply not consulted on the first two.

Suggested fix

Apply the existing gate once, to whichever candidate survives, instead of only inside the function branch:

if len(candidates) != 1:
    continue
fams = stub_families.get(stub_id, set())
cand_fam = _lang_family(candidates[0].get("source_file"))
if fams and cand_fam is not None and cand_fam not in fams:
    continue
target_id = candidates[0].get("id")

I ran this against the reproduction above: cross-language edges drop from 5 to 0, and the result is the expected three separate nodes (three_path with source_file='three.js', plus shape_swift_path and build_py_path as unresolved stubs). The native family grouping still lets Swift↔Objective-C↔C++ resolve normally.

Why it matters beyond the edge count

On a real mixed-language repo (~29.5k nodes, ~67.8k edges — Swift app, vendored JS viewer, Python build scripts) this produced only 12 phantom edges, 0.018% of the graph — but they were nearly all incident to one node, and they connected otherwise-disjoint language clusters. That is the worst possible shape for betweenness:

betweenness rank
as extracted 0.2253 #2 of 29,468
12 phantom edges removed 0.0028 #215

The JS Path class became the second-highest-betweenness node in the entire graph, and graphify's own suggested_questions surfaced it as the top "cross-community bridge" to investigate. The whole god-node/betweenness top-5 changed once the phantoms were dropped, replacing vendor plumbing with the application's real architectural seams.

So the practical impact is not proportional to the edge count: a handful of these can dominate every centrality-derived output (God Nodes, Surprising Connections, Suggested Questions) while leaving neighbourhood queries unaffected — which makes it easy to miss.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions