Skip to content

Commit 67f4f83

Browse files
safishamsiclaude
andcommitted
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>
1 parent 96db75c commit 67f4f83

2 files changed

Lines changed: 59 additions & 0 deletions

File tree

graphify/extract.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12026,6 +12026,13 @@ def _key(label: str) -> str:
1202612026
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
1202712027
if not type_name:
1202812028
continue
12029+
# A builtin global receiver type (Date, Promise, Map, ...) must not resolve
12030+
# to a user symbol. _key() casefolds, so `x: Date; x.getTime()` would bind
12031+
# the caller to a same-named user `class DATE` in another file, inventing
12032+
# phantom `references[call]` edges and a false god node (#1726). The
12033+
# cross-file CALL resolver already skips these globals; do the same here.
12034+
if type_name in _LANGUAGE_BUILTIN_GLOBALS:
12035+
continue
1202912036
type_defs = type_def_nids.get(_key(type_name), [])
1203012037
if len(type_defs) != 1:
1203112038
continue
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Builtin-global receiver types must not resolve to same-named user symbols.
2+
3+
#1726: `x: Date; x.getTime()` had its caller bound (by casefolded label) to a
4+
user `class DATE` / `const DATE` in another file, inventing phantom
5+
`references[call]` edges and a false god node. The cross-file CALL resolver
6+
already skips ECMAScript/Python builtins; `_resolve_typescript_member_calls`
7+
must do the same.
8+
"""
9+
from pathlib import Path
10+
from graphify.extract import extract
11+
12+
13+
def _labels_by_id(r):
14+
return {n["id"]: n.get("label") for n in r["nodes"]}
15+
16+
17+
def test_builtin_date_type_ref_does_not_bind_to_user_DATE(tmp_path):
18+
(tmp_path / "model.ts").write_text('export class DATE {\n value: string = "";\n}\n')
19+
(tmp_path / "a.ts").write_text('export function parse(x: Date): number { return x.getTime(); }\n')
20+
(tmp_path / "b.ts").write_text('export function fmt(w: Date): string { return w.toISOString(); }\n')
21+
r = extract(sorted(tmp_path.glob("*.ts")), cache_root=tmp_path, parallel=False)
22+
lbl = _labels_by_id(r)
23+
date_ids = [n["id"] for n in r["nodes"] if n.get("label") == "DATE"]
24+
assert date_ids, "the user class DATE must still exist as a node"
25+
for e in r["edges"]:
26+
if e.get("relation") == "references" and e.get("target") in date_ids:
27+
src = lbl.get(e["source"])
28+
assert False, f"phantom builtin-Date reference bound to user DATE from {src!r}"
29+
# the user DATE node accumulates no phantom references — degree is just its file
30+
deg = sum(1 for e in r["edges"] if date_ids[0] in (e["source"], e["target"]))
31+
assert deg <= 1, f"user DATE should not be a god node; degree={deg}"
32+
33+
34+
def test_nonbuiltin_receiver_type_still_resolves(tmp_path):
35+
# Guard must be a no-op for a genuine user type: a member call on a user-typed
36+
# field still resolves cross-file (constructor-injection type table, #1316).
37+
(tmp_path / "svc.ts").write_text(
38+
"export class PaymentClient {\n charge(n: number): boolean { return true; }\n}\n")
39+
(tmp_path / "order.ts").write_text(
40+
'import { PaymentClient } from "./svc";\n'
41+
"export class Order {\n"
42+
" constructor(private client: PaymentClient) {}\n"
43+
" pay(): boolean { return this.client.charge(1); }\n"
44+
"}\n")
45+
r = extract(sorted(tmp_path.glob("*.ts")), cache_root=tmp_path, parallel=False)
46+
lbl = _labels_by_id(r)
47+
resolved = {
48+
(lbl.get(e["source"]), lbl.get(e["target"]), e["relation"])
49+
for e in r["edges"] if "charge" in str(e.get("target", "")).lower()
50+
}
51+
assert any(t and "charge" in str(t).lower() for _, t, _ in resolved), \
52+
f"user member-call must still resolve; got {resolved}"

0 commit comments

Comments
 (0)