mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-21 11:21:27 +00:00
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:
1 parent
e8eb76e797
commit
1d2e2bc47e
3 files changed
+148
-12
No files matched your search
+26
-4
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user