From 1d2e2bc47e17048a19e65076a43bb9ce0a5841e5 Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Sat, 10 Oct 2026 22:15:13 +0530 Subject: [PATCH] fix(ts): scope class-field receiver types to the field's own class Class fields went into the per-file receiver table, so two classes in one file with a same-named field of different types typed each other's `this.repo` (`A.run` bound to `RepoB.save`), and a local named like a field took the field's type. Only the first commit of #4231 shipped in 0.9.81; this is its second one. Fields now go to a per-class table (`ts_type_table["fields"][Class][field]`), and only a `this.x.m()` call, marked `ts_this_field` on its raw call, reads it through the caller's own class. `const { x } = this` / `{ x: y } = this` in the enclosing function mark `x.m()` / `y.m()` the same way, and classes are matched casefolded like node ids (`class UsecaseVisitor` beside `const usecaseVisitor`). Every other receiver uses the file table as before. vs 0.9.84: excalidraw -1 wrong / +2 correct edges, TypeORM -2 (a local sharing a field's name), NestJS, hono, mermaid, typescript-eslint unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf --- graphify/extract.py | 30 +++++++-- graphify/extractors/engine.py | 85 +++++++++++++++++++++++--- tests/test_ts_receiver_member_calls.py | 45 ++++++++++++++ 3 files changed, 148 insertions(+), 12 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index b2e7d77a8..a1e52ff5b 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5009,17 +5009,30 @@ def _resolve_typescript_member_calls( for a merged graph to finish (#3152); the gate above never sees it, since it only vets a name that did match locally. """ + def _key(label: str) -> str: + return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() + type_table_by_file: dict[str, dict[str, str]] = {} + # Class fields, per file then per class: `this.x.m()` reads only the + # caller's own class, so a same-named field of another class never types it. + # Classes are keyed like node ids (casefolded): `class UsecaseVisitor` next to + # `const usecaseVisitor` is one node, labeled after either name. Two classes + # folding together keep only the fields they type the same way. + field_types_by_file: dict[str, 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", {}) + by_class: dict[str, dict[str, str]] = {} + for cls, fields in (tt.get("fields") or {}).items(): + merged = by_class.setdefault(_key(cls), {}) + for fname, ftype in fields.items(): + merged[fname] = ftype if merged.get(fname, ftype) == ftype else "" + if by_class: + field_types_by_file[tt["path"]] = by_class 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"} # Only the JS/TS interop family declares a type this receiver can hold (SFC script @@ -5034,6 +5047,7 @@ def _resolve_typescript_member_calls( type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) method_index: dict[tuple[str, str], str] = {} + class_of_method: dict[str, str] = {} for e in all_edges: if e.get("relation") != "method": continue @@ -5041,6 +5055,8 @@ def _resolve_typescript_member_calls( tnode = node_by_id.get(tgt) if tnode is not None: method_index[(src, _key(tnode.get("label", "")))] = tgt + if isinstance(src, str) and isinstance(tgt, str): + class_of_method[tgt] = src # Origin maps (#2553), built like the Python resolver's module arm: key on # stable NODE ids, not source_file strings (raw_calls keep their original @@ -5083,7 +5099,13 @@ def _resolve_typescript_member_calls( type_qualified = True # the receiver names the type in source else: type_qualified = False - type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) + type_name = None + if rc.get("ts_this_field"): + owner_label = node_by_id.get(class_of_method.get(caller, ""), {}).get("label", "") + type_name = (field_types_by_file.get(rc.get("source_file", ""), {}) + .get(_key(str(owner_label)), {}).get(receiver)) + if not type_name: + 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 diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index d7d991a86..3d056104a 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3021,7 +3021,10 @@ def _csharp_scoped_receiver_type( return inner[0][2] return None -def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None: +def _ts_receiver_type_table( + root, source: bytes, table: dict[str, str], + field_table: "dict[str, dict[str, str]] | None" = None, +) -> None: """Add TS/JS receiver bindings to ``table`` (name -> TypeName), for member-call resolution beyond the constructor-injected `this.field` case (#1630): @@ -3078,13 +3081,17 @@ def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None: name = _read_text(pat, source) if name and tname and name not in table: table[name] = tname - elif t in ("public_field_definition", "field_definition"): + elif t in ("public_field_definition", "field_definition") and field_table is not None: # A class field (`protected repo: Repo`, `#root: Node = new Node()`, # `private logger = new Logger()`) is what `this.repo.m()` reads, the - # same as a constructor parameter property. Static fields are read as - # `Cls.field`, not `this.field`, so they are skipped. + # same as a constructor parameter property. It is recorded under its + # own class only, so two classes in one file with a same-named field + # of different types never borrow each other's type. Static fields are + # read as `Cls.field`, not `this.field`, so they are skipped. name_n = n.child_by_field_name("name") or n.child_by_field_name("property") - if (name_n is not None + owner = n.parent.parent if n.parent is not None else None + owner_name = owner.child_by_field_name("name") if owner is not None else None + if (name_n is not None and owner_name is not None and name_n.type in ("property_identifier", "private_property_identifier") and not any(c.type == "static" for c in n.children)): ann = n.child_by_field_name("type") @@ -3097,11 +3104,52 @@ def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None: if ctor is not None and ctor.type in ("identifier", "type_identifier"): tname = _read_text(ctor, source) name = _read_text(name_n, source) - if name and tname and name not in table: - table[name] = tname + fields = field_table.setdefault(_read_text(owner_name, source), {}) + if name and tname and name not in fields: + fields[name] = tname for c in n.children: stack.append(c) +_TS_FUNCTION_NODES = frozenset({ + "arrow_function", "method_definition", "function_declaration", "function_expression", + "generator_function_declaration", "generator_function", +}) + + +def _ts_field_destructured_from_this(ident, name: str, source: bytes) -> str | None: + """The `this` field that ``name`` was destructured from, or None. + + `const { creator } = this` and `const { creator: c } = this` in the function + around the call (or around an arrow function holding it, since an arrow keeps + the outer `this`) make `creator` / `c` the `creator` field. The search stops at + the first function that binds its own `this`, and only reads declarations of + that function itself, not of functions nested in it.""" + node = ident.parent + while node is not None: + if node.type in _TS_FUNCTION_NODES: + body = node.child_by_field_name("body") + stack = [body] if body is not None else [] + while stack: + n = stack.pop() + if n.type == "variable_declarator": + pattern, value = n.child_by_field_name("name"), n.child_by_field_name("value") + if (pattern is not None and pattern.type == "object_pattern" + and value is not None and value.type == "this"): + for p in pattern.named_children: + if p.type == "shorthand_property_identifier_pattern" and _read_text(p, source) == name: + return name + if p.type == "pair_pattern": + key, val = p.child_by_field_name("key"), p.child_by_field_name("value") + if (key is not None and val is not None and val.type == "identifier" + and _read_text(val, source) == name): + return _read_text(key, source) + stack.extend(c for c in n.children if c.type not in _TS_FUNCTION_NODES) + if node.type != "arrow_function": + return None + node = node.parent + return None + + def _find_require_call(value_node): """Return the call_expression node if `value_node` is a `require(...)` call or `require(...).x` member access. Otherwise None.""" @@ -8579,6 +8627,13 @@ def _extract_generic( 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) + if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): + # `const { creator } = this; creator.m()` reads the + # field the same way `this.creator.m()` does. + _this_field = _ts_field_destructured_from_this(obj, member_receiver, source) + if _this_field: + member_receiver = _this_field + is_this_field_call = True elif ( obj is not None and obj.type in ("this", "super") @@ -8827,6 +8882,12 @@ def _extract_generic( _sargs = _sobj.child_by_field_name("arguments") if _sobj is not None else None if _sargs is not None and any(c.is_named for c in _sargs.children): rc_entry["_python_super_args"] = True + # TS/JS `this.x.m()`: the receiver is a field of the caller's + # own class, which the per-class field table types. + if is_this_field_call and config.ts_module in ( + "tree_sitter_javascript", "tree_sitter_typescript", + ): + rc_entry["ts_this_field"] = True # Tag the C++ raw_call's language so the cross-file C++ resolver # claims it unambiguously: a `.h` file routes to extract_cpp or # extract_objc by content, and both resolvers see `.h` in their @@ -9399,9 +9460,15 @@ def _extract_generic( # a call on a typed param (incl. inside a closure) resolve (#1630). The # constructor-injection entries are populated during the walk above and win on # a name clash (first-binding-wins in the helper). + # Class fields go to their own per-class table (`this.x` only reads the + # caller's class), never into the file-wide name table. + ts_field_types: dict[str, dict[str, str]] = {} if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): - _ts_receiver_type_table(root, source, type_table) + _ts_receiver_type_table(root, source, type_table, ts_field_types) _ts_mark_public_twins(root, source, result) + ts_field_types = {cls: f for cls, f in ts_field_types.items() if f} + if ts_field_types and not type_table: + result["ts_type_table"] = {"path": str_path, "table": {}, "fields": ts_field_types} if config.ts_module == "tree_sitter_php": _php_receiver_type_table(root, source, type_table) if config.ts_module == "tree_sitter_swift": @@ -9415,6 +9482,8 @@ def _extract_generic( elif type_table: if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): result["ts_type_table"] = {"path": str_path, "table": type_table} + if ts_field_types: + result["ts_type_table"]["fields"] = ts_field_types elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_php": diff --git a/tests/test_ts_receiver_member_calls.py b/tests/test_ts_receiver_member_calls.py index 59f7c049e..81d0fde39 100644 --- a/tests/test_ts_receiver_member_calls.py +++ b/tests/test_ts_receiver_member_calls.py @@ -380,3 +380,48 @@ def test_a_class_declared_in_an_sfc_script_block_still_answers(tmp_path): " greet(): void {}\n}\n\n"), }) assert any("run" in str(s) and "greet" in str(t) for s, t in calls), calls + + +def test_same_named_fields_in_two_classes_of_one_file_keep_their_own_type(tmp_path): + # Fields are scoped to their class: B's `repo: RepoB` must not type A's `this.repo`. + _, r = _calls(tmp_path, { + "repos.ts": ("export class RepoA {\n save(): number { return 1; }\n}\n" + "export class RepoB {\n save(): number { return 2; }\n}\n"), + "two.ts": ('import { RepoA, RepoB } from "./repos";\n' + "export class A {\n repo: RepoA = new RepoA();\n run(): number { return this.repo.save(); }\n}\n" + "export class B {\n repo: RepoB = new RepoB();\n run(): number { return this.repo.save(); }\n}\n"), + }) + nodes = {n["id"]: n for n in r["nodes"]} + owner = {e["target"]: nodes[e["source"]]["label"] for e in r["edges"] if e["relation"] == "method"} + pairs = {(owner.get(e["source"]), owner.get(e["target"])) for e in r["edges"] if e["relation"] == "calls"} + assert pairs == {("A", "RepoA"), ("B", "RepoB")}, pairs + + +def test_field_destructured_from_this_types_the_local(tmp_path): + # `const { repo } = this` (also inside an arrow) and `{ repo: r } = this` read + # the field exactly as `this.repo` does. + calls, _ = _calls(tmp_path, { + "repo.ts": "export class Repo {\n save(): number { return 1; }\n}\n", + "svc.ts": ('import { Repo } from "./repo";\n' + "export class Svc {\n" + " private repo = new Repo();\n" + " a(): number { const { repo } = this; return repo.save(); }\n" + " b(): number { const { repo: r } = this; return [1].map(() => r.save())[0]; }\n" + "}\n"), + }) + assert _caller_hits(calls, "a", "save") and _caller_hits(calls, "b", "save"), calls + + +def test_class_sharing_its_node_with_a_same_named_value_keeps_its_fields(tmp_path): + # `class Visitor` and `const visitor` fold into one node id; the class's + # fields must still type its `this.repo` calls. + calls, _ = _calls(tmp_path, { + "repo.ts": "export class Repo {\n save(): number { return 1; }\n}\n", + "visitor.ts": ('import { Repo } from "./repo";\n' + "export const visitor = 1;\n" + "class Visitor {\n" + " private repo = new Repo();\n" + " a(): number { return this.repo.save(); }\n" + "}\n"), + }) + assert _caller_hits(calls, "a", "save"), calls