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:
safishamsi
2026-07-08 11:25:28 +01:00
parent 96db75c8c2
commit 67f4f835f7
2 changed files with 59 additions and 0 deletions
+7
View File
@@ -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
+52
View File
@@ -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}"