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.
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 SwiftPathand a Pythonpathlib.Pathboth bind to a JavaScriptclass Pathwhen 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:
Actual
Five cross-language edges, all binding to the JS class:
Note
build.pyhas an explicitfrom pathlib import Path, and graphify even emits theimports_from build -> pathlibedge — 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):stub_familiesand_lang_familyare 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:
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_pathwithsource_file='three.js', plusshape_swift_pathandbuild_py_pathas unresolved stubs). Thenativefamily 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:
The JS
Pathclass became the second-highest-betweenness node in the entire graph, andgraphify's ownsuggested_questionssurfaced 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.