From 67f4f835f7634cf31ae4bdc2d859a3561efee1b9 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 8 Jul 2026 11:25:28 +0100 Subject: [PATCH] fix(extract): skip builtin-global receiver types in TS/JS member-call resolution (#1726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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) --- graphify/extract.py | 7 ++++ tests/test_builtin_global_type_refs.py | 52 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/test_builtin_global_type_refs.py diff --git a/graphify/extract.py b/graphify/extract.py index ed055043..16a3f753 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -12026,6 +12026,13 @@ def _resolve_typescript_member_calls( type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) if not type_name: 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), []) if len(type_defs) != 1: continue diff --git a/tests/test_builtin_global_type_refs.py b/tests/test_builtin_global_type_refs.py new file mode 100644 index 00000000..0fe1fd72 --- /dev/null +++ b/tests/test_builtin_global_type_refs.py @@ -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}"