mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 10:57:13 +00:00
fix(extract): skip builtin-global receiver types in TS/JS member-call resolution (#1726)
`_resolve_typescript_member_calls` resolves a member call's receiver to a type
definition by casefolded label. For a builtin-typed receiver (`x: Date;
x.getTime()`), `_key("Date")` == "date" matched a same-named user `class DATE` /
`const DATE` in another file, binding the caller to it as a phantom
`references[call]` edge. In a real 3,368-file repo one module-local `const DATE`
accumulated 469 false cross-file edges (degree 472, betweenness 0.435) — a false
god node distorting path/god-node results.
Skip the resolution when the receiver type is an ECMAScript/Python builtin
global (Date, Promise, Map, ...), mirroring the guard the cross-file CALL
resolver already applies (#726). The guard is a strict no-op for genuine user
types, so legitimate constructor-injection member-call resolution (#1316) is
unaffected. Verified across new Date().method(), return-type, and var-decl-type
shapes: zero phantom edges, user DATE degree back to 1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12026,6 +12026,13 @@ def _resolve_typescript_member_calls(
|
|||||||
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
|
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
|
||||||
if not type_name:
|
if not type_name:
|
||||||
continue
|
continue
|
||||||
|
# A builtin global receiver type (Date, Promise, Map, ...) must not resolve
|
||||||
|
# to a user symbol. _key() casefolds, so `x: Date; x.getTime()` would bind
|
||||||
|
# the caller to a same-named user `class DATE` in another file, inventing
|
||||||
|
# phantom `references[call]` edges and a false god node (#1726). The
|
||||||
|
# cross-file CALL resolver already skips these globals; do the same here.
|
||||||
|
if type_name in _LANGUAGE_BUILTIN_GLOBALS:
|
||||||
|
continue
|
||||||
type_defs = type_def_nids.get(_key(type_name), [])
|
type_defs = type_def_nids.get(_key(type_name), [])
|
||||||
if len(type_defs) != 1:
|
if len(type_defs) != 1:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Builtin-global receiver types must not resolve to same-named user symbols.
|
||||||
|
|
||||||
|
#1726: `x: Date; x.getTime()` had its caller bound (by casefolded label) to a
|
||||||
|
user `class DATE` / `const DATE` in another file, inventing phantom
|
||||||
|
`references[call]` edges and a false god node. The cross-file CALL resolver
|
||||||
|
already skips ECMAScript/Python builtins; `_resolve_typescript_member_calls`
|
||||||
|
must do the same.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from graphify.extract import extract
|
||||||
|
|
||||||
|
|
||||||
|
def _labels_by_id(r):
|
||||||
|
return {n["id"]: n.get("label") for n in r["nodes"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_date_type_ref_does_not_bind_to_user_DATE(tmp_path):
|
||||||
|
(tmp_path / "model.ts").write_text('export class DATE {\n value: string = "";\n}\n')
|
||||||
|
(tmp_path / "a.ts").write_text('export function parse(x: Date): number { return x.getTime(); }\n')
|
||||||
|
(tmp_path / "b.ts").write_text('export function fmt(w: Date): string { return w.toISOString(); }\n')
|
||||||
|
r = extract(sorted(tmp_path.glob("*.ts")), cache_root=tmp_path, parallel=False)
|
||||||
|
lbl = _labels_by_id(r)
|
||||||
|
date_ids = [n["id"] for n in r["nodes"] if n.get("label") == "DATE"]
|
||||||
|
assert date_ids, "the user class DATE must still exist as a node"
|
||||||
|
for e in r["edges"]:
|
||||||
|
if e.get("relation") == "references" and e.get("target") in date_ids:
|
||||||
|
src = lbl.get(e["source"])
|
||||||
|
assert False, f"phantom builtin-Date reference bound to user DATE from {src!r}"
|
||||||
|
# the user DATE node accumulates no phantom references — degree is just its file
|
||||||
|
deg = sum(1 for e in r["edges"] if date_ids[0] in (e["source"], e["target"]))
|
||||||
|
assert deg <= 1, f"user DATE should not be a god node; degree={deg}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonbuiltin_receiver_type_still_resolves(tmp_path):
|
||||||
|
# Guard must be a no-op for a genuine user type: a member call on a user-typed
|
||||||
|
# field still resolves cross-file (constructor-injection type table, #1316).
|
||||||
|
(tmp_path / "svc.ts").write_text(
|
||||||
|
"export class PaymentClient {\n charge(n: number): boolean { return true; }\n}\n")
|
||||||
|
(tmp_path / "order.ts").write_text(
|
||||||
|
'import { PaymentClient } from "./svc";\n'
|
||||||
|
"export class Order {\n"
|
||||||
|
" constructor(private client: PaymentClient) {}\n"
|
||||||
|
" pay(): boolean { return this.client.charge(1); }\n"
|
||||||
|
"}\n")
|
||||||
|
r = extract(sorted(tmp_path.glob("*.ts")), cache_root=tmp_path, parallel=False)
|
||||||
|
lbl = _labels_by_id(r)
|
||||||
|
resolved = {
|
||||||
|
(lbl.get(e["source"]), lbl.get(e["target"]), e["relation"])
|
||||||
|
for e in r["edges"] if "charge" in str(e.get("target", "")).lower()
|
||||||
|
}
|
||||||
|
assert any(t and "charge" in str(t).lower() for _, t, _ in resolved), \
|
||||||
|
f"user member-call must still resolve; got {resolved}"
|
||||||
Reference in New Issue
Block a user