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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
c8c604d08c
commit
1801da0634
+134
-4
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user