From 1801da0634e728533811ddcb648b38d5d4a900eb Mon Sep 17 00:00:00 2001 From: guy oron Date: Tue, 30 Jun 2026 09:46:16 +0100 Subject: [PATCH] feat(ts): resolve this.field.method() calls via constructor-injection types (#1316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- graphify/extract.py | 138 +++++++++++++++++++++++- tests/fixtures/typescript_advanced.ts | 4 + tests/test_languages.py | 148 ++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index ee27127..9cf619f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: diff --git a/tests/fixtures/typescript_advanced.ts b/tests/fixtures/typescript_advanced.ts index bd271c1..076d10b 100644 --- a/tests/fixtures/typescript_advanced.ts +++ b/tests/fixtures/typescript_advanced.ts @@ -57,6 +57,10 @@ export class UserService { bulkCreate(names: string[]): User[] { return names.map((n) => new User(n)); } + + getById(id: string): Promise { + return this.repo.findById(id); + } } @Module({ diff --git a/tests/test_languages.py b/tests/test_languages.py index a7d3ea9..98e522c 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -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;\n" + " save(user: any): Promise;\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