feat(ts): resolve this.field.method() calls via constructor-injection types (#1316)

A member call through a constructor-injected dependency
(`constructor(private db: Database)` ... `this.db.query()`) now produces
a calls edge to the field type's method. The field->type map is captured
from constructor parameter-properties, and resolution reuses the existing
single-definition god-node guard (like the Swift/Python/Ruby member-call
resolvers): the edge is emitted only when the field's type name resolves
to exactly one class definition that owns the method, so an ambiguous or
unknown/untyped field produces no edge — no global name-match fan-out.
Edges are EXTRACTED (the type is explicit from the annotation). TS/JS-only
and additive; scope is constructor parameter-property injection.

Adds the decisive regression tests the implementation needed: two classes
defining the same method name where the injected field is typed to one of
them (must resolve to that one only), and an ambiguous type-name case
(must emit no edge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
guy oron
2026-06-30 09:46:16 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent c8c604d08c
commit 1801da0634
3 changed files with 286 additions and 4 deletions
+134 -4
View File
@@ -2193,6 +2193,7 @@ _JS_CONFIG = LanguageConfig(
call_function_field="function",
call_accessor_node_types=frozenset({"member_expression"}),
call_accessor_field="property",
call_accessor_object_field="object",
function_boundary_types=frozenset({"function_declaration", "arrow_function", "method_definition"}),
import_handler=_import_js,
)
@@ -2207,12 +2208,13 @@ _TS_CONFIG = LanguageConfig(
"enum_declaration", # named enums
"type_alias_declaration", # named type aliases
}),
function_types=frozenset({"function_declaration", "method_definition"}),
function_types=frozenset({"function_declaration", "method_definition", "method_signature"}),
import_types=frozenset({"import_statement", "export_statement"}),
call_types=frozenset({"call_expression", "new_expression"}),
call_function_field="function",
call_accessor_node_types=frozenset({"member_expression"}),
call_accessor_field="property",
call_accessor_object_field="object",
function_boundary_types=frozenset({"function_declaration", "arrow_function", "method_definition"}),
import_handler=_import_js,
)
@@ -2231,6 +2233,7 @@ _TSX_CONFIG = LanguageConfig(
call_function_field=_TS_CONFIG.call_function_field,
call_accessor_node_types=_TS_CONFIG.call_accessor_node_types,
call_accessor_field=_TS_CONFIG.call_accessor_field,
call_accessor_object_field=_TS_CONFIG.call_accessor_object_field,
function_boundary_types=_TS_CONFIG.function_boundary_types,
import_handler=_TS_CONFIG.import_handler,
)
@@ -3600,6 +3603,31 @@ def _extract_generic(
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
if (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript")
and func_name == "constructor"):
params_node = node.child_by_field_name("parameters")
if params_node is not None:
for p in params_node.children:
if p.type != "required_parameter":
continue
has_modifier = any(
c.type in ("accessibility_modifier", "readonly")
for c in p.children
)
if not has_modifier:
continue
name_n = p.child_by_field_name("pattern")
type_n = p.child_by_field_name("type")
if name_n is None or type_n is None:
continue
pname = _read_text(name_n, source)
for tc in type_n.children:
if tc.type == "type_identifier":
ptype = _read_text(tc, source)
if pname and ptype:
type_table[pname] = ptype
break
if config.ts_module in ("tree_sitter_c", "tree_sitter_cpp"):
collect = (_cpp_collect_type_refs if config.ts_module == "tree_sitter_cpp"
else _c_collect_type_refs)
@@ -3791,6 +3819,7 @@ def _extract_generic(
callee_name: str | None = None
is_member_call: bool = False
is_this_field_call: bool = False
swift_receiver: str | None = None
member_receiver: str | None = None
@@ -3927,10 +3956,20 @@ def _extract_generic(
# Capture a simple-identifier receiver (e.g. `ClassName`
# in `ClassName.method()`) so cross-file member-call
# resolution can resolve qualified class-method calls
# (#1446). Chained receivers (`a.b.method()`) are skipped.
# (#1446). Chained receivers (`a.b.method()`) are skipped
# UNLESS the chain is `this.field.method()` (#1316).
obj = func_node.child_by_field_name(config.call_accessor_object_field)
if obj is not None and obj.type == "identifier":
member_receiver = _read_text(obj, source)
elif (obj is not None
and obj.type in config.call_accessor_node_types
and config.call_accessor_object_field):
inner_obj = obj.child_by_field_name(config.call_accessor_object_field)
if inner_obj is not None and inner_obj.type == "this":
inner_prop = obj.child_by_field_name(config.call_accessor_field)
if inner_prop is not None:
member_receiver = _read_text(inner_prop, source)
is_this_field_call = True
else:
# Try reading the node directly (e.g. Java name field is the callee)
callee_name = _read_text(func_node, source)
@@ -3942,7 +3981,7 @@ def _extract_generic(
# viewset action delegates to a same-named service action — which would
# match `tgt_nid == caller_nid` and silently drop the call (#1446). The
# captured receiver is resolved later in _resolve_python_member_calls.
if is_member_call and member_receiver and member_receiver[:1].isupper():
if is_member_call and member_receiver and (member_receiver[:1].isupper() or is_this_field_call):
tgt_nid = None
else:
tgt_nid = label_to_nid.get(callee_name)
@@ -4153,7 +4192,10 @@ def _extract_generic(
if swift_extensions:
result["swift_extensions"] = swift_extensions
if type_table:
result["swift_type_table"] = {"path": str_path, "table": type_table}
if config.ts_module == "tree_sitter_swift":
result["swift_type_table"] = {"path": str_path, "table": type_table}
elif config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
result["ts_type_table"] = {"path": str_path, "table": type_table}
return result
@@ -9783,6 +9825,91 @@ def _resolve_python_member_calls(
})
def _resolve_typescript_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve cross-file TS/JS member calls via constructor-injection type tables (#1316).
``this.repo.findById()`` drops out in the shared cross-file pass because bare
``findById`` collides across the corpus (god-node guard). TS constructors with
parameter-property modifiers (``private repo: IUserRepository``) produce a
per-file type table mapping field names to their declared types. This pass
looks up the receiver field's type, finds a single-definition class/interface
owning a method with the callee name, and emits an EXTRACTED ``calls`` edge.
"""
type_table_by_file: dict[str, dict[str, str]] = {}
for result in per_file:
tt = result.get("ts_type_table")
if tt and tt.get("path"):
type_table_by_file[tt["path"]] = tt.get("table", {})
if not type_table_by_file:
return
def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
type_def_nids: dict[str, list[str]] = {}
node_by_id: dict[str, dict] = {}
for n in all_nodes:
node_by_id[n.get("id")] = n
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
method_index: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
src, tgt = e.get("source"), e.get("target")
tnode = node_by_id.get(tgt)
if tnode is not None:
method_index[(src, _key(tnode.get("label", "")))] = tgt
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in all_raw_calls:
if not rc.get("is_member_call"):
continue
receiver = rc.get("receiver")
callee = rc.get("callee")
caller = rc.get("caller_nid")
if not receiver or not callee or not caller:
continue
if receiver[:1].isupper():
type_name = receiver
else:
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
if not type_name:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1:
continue
type_nid = type_defs[0]
method_nid = method_index.get((type_nid, _key(callee)))
target = method_nid or type_nid
relation = "calls" if method_nid else "references"
if target == caller or (caller, target) in existing_pairs:
continue
existing_pairs.add((caller, target))
all_edges.append({
"source": caller,
"target": target,
"relation": relation,
"context": "call",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
# Register the cross-file, language-specific member-call resolvers into the shared
# registry (framework lives in graphify.resolver_registry). A new language plugs in
# by adding one register() call below — no edits to extract()'s body. Order
@@ -9798,6 +9925,9 @@ register_language_resolver(
register_language_resolver(
LanguageResolver("ruby_member_calls", frozenset({".rb"}), resolve_ruby_member_calls)
)
register_language_resolver(
LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".js", ".jsx"}), _resolve_typescript_member_calls)
)
def extract_objc(path: Path) -> dict:
+4
View File
@@ -57,6 +57,10 @@ export class UserService {
bulkCreate(names: string[]): User[] {
return names.map((n) => new User(n));
}
getById(id: string): Promise<User | null> {
return this.repo.findById(id);
}
}
@Module({
+148
View File
@@ -1717,6 +1717,154 @@ def test_ts_local_const_does_not_emit_phantom_node(tmp_path):
assert "topLevel" in labels, f"module-level TS const 'topLevel' missing: {labels}"
def test_ts_constructor_injection_calls_edge(tmp_path):
"""this.repo.findById() in a class with constructor(private repo: IUserRepository)
must produce a calls edge from getUser() to findById() (#1316)."""
from graphify.extract import extract
repo_ts = tmp_path / "repo.ts"
repo_ts.write_text(
"export interface IUserRepository {\n"
" findById(id: string): Promise<any>;\n"
" save(user: any): Promise<void>;\n"
"}\n"
)
svc_ts = tmp_path / "service.ts"
svc_ts.write_text(
"import { IUserRepository } from './repo';\n"
"\n"
"export class UserService {\n"
" constructor(private repo: IUserRepository) {}\n"
"\n"
" getUser(id: string) {\n"
" return this.repo.findById(id);\n"
" }\n"
"}\n"
)
r = extract([repo_ts, svc_ts], cache_root=tmp_path / "cache")
edge_triples = {
(e["source"], e["relation"], e["target"])
for e in r["edges"]
}
labels_by_id = {n["id"]: n["label"] for n in r["nodes"]}
label_triples = {
(labels_by_id.get(s, s), rel, labels_by_id.get(t, t))
for s, rel, t in edge_triples
}
calls_from_get_user = [
(s, rel, t) for s, rel, t in label_triples
if "getUser" in s and rel == "calls"
]
assert any("findById" in t for _, _, t in calls_from_get_user), (
f"expected getUser()->findById() calls edge, got: {calls_from_get_user}"
)
def test_ts_this_field_receiver_not_same_file_collision(tmp_path):
"""this.db.query() should NOT match an unrelated query() in the same file (#1316)."""
f = tmp_path / "collision.ts"
f.write_text(
"function query() { return 'global'; }\n"
"\n"
"export class Service {\n"
" constructor(private db: Database) {}\n"
"\n"
" run() {\n"
" return this.db.query();\n"
" }\n"
"}\n"
)
r = extract_js(f)
calls_edges = [
e for e in r["edges"]
if e["relation"] == "calls"
]
caller_labels = {n["id"]: n["label"] for n in r["nodes"]}
run_to_query = [
e for e in calls_edges
if "run" in caller_labels.get(e["source"], "")
and "query" in caller_labels.get(e["target"], "")
]
assert len(run_to_query) == 0, (
f"this.db.query() should NOT resolve to bare query() in same file: {run_to_query}"
)
def _ts_label_calls(r, src_sub):
labels = {n["id"]: n["label"] for n in r["nodes"]}
return [
labels.get(e["target"], e["target"])
for e in r["edges"]
if e["relation"] == "calls" and src_sub in labels.get(e["source"], e["source"])
]
def test_ts_injected_field_resolves_to_typed_class_not_same_named_collision(tmp_path):
"""The decisive #1316 guardrail: two classes each define `query`, but the
injected field is typed `Database`, so `this.db.query()` must resolve to
Database.query ONLY — never HttpClient.query (no global name-match fan-out)."""
from graphify.extract import extract
(tmp_path / "database.ts").write_text(
"export class Database {\n query(sql: string) { return sql; }\n}\n"
)
(tmp_path / "http.ts").write_text(
"export class HttpClient {\n query(url: string) { return url; }\n}\n"
)
(tmp_path / "service.ts").write_text(
"import { Database } from './database';\n"
"export class Service {\n"
" constructor(private db: Database) {}\n"
" run() { return this.db.query('x'); }\n"
"}\n"
)
r = extract(
[tmp_path / "database.ts", tmp_path / "http.ts", tmp_path / "service.ts"],
cache_root=tmp_path / "cache",
)
labels = {n["id"]: n["label"] for n in r["nodes"]}
# Find the run()->query calls edge and confirm its target is owned by Database.
method_owner = {
e["target"]: e["source"]
for e in r["edges"] if e["relation"] == "method"
}
run_query_targets = [
e["target"] for e in r["edges"]
if e["relation"] == "calls"
and "run" in labels.get(e["source"], "")
and "query" in labels.get(e["target"], "")
]
assert run_query_targets, "expected this.db.query() to resolve to a query method"
for tgt in run_query_targets:
owner = method_owner.get(tgt)
assert owner is not None and labels.get(owner) == "Database", (
f"this.db.query() must resolve to Database.query, got owner {labels.get(owner)}"
)
def test_ts_injected_field_ambiguous_type_emits_no_edge(tmp_path):
"""If the injected field's type name is ambiguous (two classes named Database),
the god-node guard bails — no calls edge rather than a guess (#1316)."""
from graphify.extract import extract
(tmp_path / "a" ).mkdir()
(tmp_path / "b").mkdir()
(tmp_path / "a" / "database.ts").write_text(
"export class Database {\n query(sql: string) { return sql; }\n}\n"
)
(tmp_path / "b" / "database.ts").write_text(
"export class Database {\n query(sql: string) { return sql; }\n}\n"
)
(tmp_path / "service.ts").write_text(
"export class Service {\n"
" constructor(private db: Database) {}\n"
" run() { return this.db.query('x'); }\n"
"}\n"
)
r = extract(sorted(tmp_path.rglob("*.ts")), cache_root=tmp_path / "cache")
# `query` resolution must bail (2 Database defs) -> no run()->query calls edge.
assert not [t for t in _ts_label_calls(r, "run") if "query" in t], (
"ambiguous Database type must not produce a this.db.query() edge"
)
# ── Markdown ─────────────────────────────────────────────────────────────────
from graphify.extract import extract_markdown