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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
This commit is contained in:
rohit-jsfreakyandClaude Opus 5.5 committed 2026-10-13 16:23:58 +05:30
1 parent e8eb76e797
commit 1d2e2bc47e
3 files changed
+148 -12

No files matched your search

+26 -4
View File
@@ -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
+77 -8
View File
@@ -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":
+45
View File
@@ -380,3 +380,48 @@ def test_a_class_declared_in_an_sfc_script_block_still_answers(tmp_path):
" greet(): void {}\n}\n</script>\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