Skip to content

Commit 02f7355

Browse files
fix(ts): read this.x from the caller's class and its bases only, with constructor, accessor and static fields
Two review findings on the per-class field table: - A `this.x` the class table did not type fell back to the file-wide name table, where constructor parameter properties of every class in the file (and locals named like a field) still lived, so the cross-class leak came back through them. `this.x` in a method of a known class now reads only that class and its bases. - Fields of an unnamed class (`export default class { .. }`) were dropped; they go to the file table again, which their class-less methods still read. To make the class-only lookup complete, a class's fields now come from declared fields, parameter properties, `this.x = param` / `this.x = new T()` in the constructor, and `get x(): T` / `set x(v: T)`; a field declared on a base class is found through `inherits` and its type is judged by the declaring file's imports; static fields are kept apart and read only from static methods. Fields live on the class node (`_ts_fields`, `_ts_static_fields`), forwarded on changed-files rebuilds together with the unchanged files' import edges, so an inherited field resolves the same as in a full build. vs 0.9.84: TypeORM `this.x.m()` 79.6% -> 96.1% (+95 edges confirmed by the compiler, 0 wrong), NestJS 78.5% -> 81.6% (+16, 0 lost), excalidraw +10 / -1 wrong; hono, mermaid, typescript-eslint unchanged. Changed-files rebuild == full build on NestJS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
1 parent fb7de42 commit 02f7355

5 files changed

Lines changed: 281 additions & 42 deletions

File tree

‎graphify/cli.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4040,6 +4040,7 @@ def _ctx_identity(source_file) -> str | None:
40404040
for _marker in (
40414041
"_callable", "_callable_class", "_elixir_module",
40424042
"_rust_impl_key", "_rust_declaration_count",
4043+
"_ts_fields", "_ts_static_fields",
40434044
):
40444045
if _node.get(_marker):
40454046
_ctx_node[_marker] = _node[_marker]
@@ -4069,11 +4070,13 @@ def _ctx_identity(source_file) -> str | None:
40694070
if _fwd_metadata:
40704071
_ctx_node["metadata"] = _fwd_metadata
40714072
_ctx_nodes.append(_ctx_node)
4073+
# Import edges come too: a TS field inherited from an unchanged file
4074+
# is typed by that file's imports, as in a full build.
40724075
for _edge in _ctx_graph.get(
40734076
"links", _ctx_graph.get("edges", [])
40744077
):
40754078
if _edge.get("relation") not in (
4076-
"contains", "method", "inherits"
4079+
"contains", "method", "inherits", "imports", "imports_from"
40774080
):
40784081
continue
40794082
if not _ctx_is_ast_tier(_edge):

‎graphify/extract.py‎

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4499,24 +4499,19 @@ def _key(label: str) -> str:
44994499
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
45004500

45014501
type_table_by_file: dict[str, dict[str, str]] = {}
4502-
# Class fields, per file then per class: `this.x.m()` reads only the
4503-
# caller's own class, so a same-named field of another class never types it.
4504-
# Classes are keyed like node ids (casefolded): `class UsecaseVisitor` next to
4505-
# `const usecaseVisitor` is one node, labeled after either name. Two classes
4506-
# folding together keep only the fields they type the same way.
4507-
field_types_by_file: dict[str, dict[str, dict[str, str]]] = {}
45084502
for result in per_file:
45094503
tt = result.get("ts_type_table")
45104504
if tt and tt.get("path"):
45114505
type_table_by_file[tt["path"]] = tt.get("table", {})
4512-
by_class: dict[str, dict[str, str]] = {}
4513-
for cls, fields in (tt.get("fields") or {}).items():
4514-
merged = by_class.setdefault(_key(cls), {})
4515-
for fname, ftype in fields.items():
4516-
merged[fname] = ftype if merged.get(fname, ftype) == ftype else ""
4517-
if by_class:
4518-
field_types_by_file[tt["path"]] = by_class
4519-
if not type_table_by_file:
4506+
# Class fields live on class nodes (`_ts_fields`). One id can carry several
4507+
# nodes (`class UsecaseVisitor` and `const usecaseVisitor` fold together), so
4508+
# gather the fields of every node with that id.
4509+
ts_fields_by_id: dict[tuple[str, bool], dict[str, str]] = {}
4510+
for n in all_nodes:
4511+
for static, attr in ((False, "_ts_fields"), (True, "_ts_static_fields")):
4512+
for fname, ftype in (n.get(attr) or {}).items():
4513+
ts_fields_by_id.setdefault((str(n.get("id")), static), {}).setdefault(fname, ftype)
4514+
if not type_table_by_file and not ts_fields_by_id:
45204515
return
45214516

45224517
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
@@ -4563,6 +4558,35 @@ def _key(label: str) -> str:
45634558
if e.get("relation") in ("imports", "imports_from"):
45644559
imported_by_filenode.setdefault(e.get("source"), set()).add(e.get("target"))
45654560

4561+
# `class B extends A`: a field A declares is B's too. Bases are matched by
4562+
# their node, else by a unique class of that label carrying fields.
4563+
bases: dict[str, list[str]] = {}
4564+
for e in all_edges:
4565+
if e.get("relation") == "inherits":
4566+
bases.setdefault(str(e.get("source")), []).append(str(e.get("target")))
4567+
field_classes_by_key: dict[str, list[str]] = {}
4568+
for n in all_nodes:
4569+
if ((n.get("_ts_fields") or n.get("_ts_static_fields"))
4570+
and n["id"] not in field_classes_by_key.get(_key(n.get("label", "")), [])):
4571+
field_classes_by_key.setdefault(_key(n.get("label", "")), []).append(n["id"])
4572+
4573+
def _field_type(class_nid: str, field: str, static: bool, depth: int = 0) -> tuple[str, str] | None:
4574+
# (field type, the class that declares the field)
4575+
found = ts_fields_by_id.get((class_nid, static), {}).get(field)
4576+
if found:
4577+
return found, class_nid
4578+
if depth >= 8:
4579+
return None
4580+
for base in bases.get(class_nid, []):
4581+
base_nid = base if (base, static) in ts_fields_by_id else None
4582+
if base_nid is None:
4583+
same = field_classes_by_key.get(_key(node_by_id.get(base, {}).get("label", "")), [])
4584+
base_nid = same[0] if len(same) == 1 else None
4585+
hit = _field_type(base_nid, field, static, depth + 1) if base_nid else None
4586+
if hit:
4587+
return hit
4588+
return None
4589+
45664590
all_raw_calls: list[dict] = []
45674591
for result in per_file:
45684592
all_raw_calls.extend(result.get("raw_calls", []))
@@ -4576,17 +4600,22 @@ def _key(label: str) -> str:
45764600
caller = rc.get("caller_nid")
45774601
if not receiver or not callee or not caller:
45784602
continue
4603+
declaring_nid: str | None = None
45794604
if receiver[:1].isupper():
45804605
type_name = receiver
45814606
type_qualified = True # the receiver names the type in source
45824607
else:
45834608
type_qualified = False
45844609
type_name = None
4585-
if rc.get("ts_this_field"):
4586-
owner_label = node_by_id.get(class_of_method.get(caller, ""), {}).get("label", "")
4587-
type_name = (field_types_by_file.get(rc.get("source_file", ""), {})
4588-
.get(_key(str(owner_label)), {}).get(receiver))
4589-
if not type_name:
4610+
owner_nid = class_of_method.get(caller) if rc.get("ts_this_field") else None
4611+
if owner_nid is not None:
4612+
# `this.x` in a method of a known class reads that class's fields
4613+
# (declared, parameter properties, constructor assignments), then
4614+
# its base classes': the file table would hand it another class's
4615+
# field or a local that merely shares the name.
4616+
hit = _field_type(owner_nid, str(receiver), bool(rc.get("ts_static_this")))
4617+
type_name, declaring_nid = hit if hit else (None, None)
4618+
else:
45904619
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
45914620
if not type_name:
45924621
continue
@@ -4606,7 +4635,9 @@ def _key(label: str) -> str:
46064635
# a class in one file and an interface in two others, and the caller's
46074636
# import says which one its `c: Context` means. Exactly one visible
46084637
# definition is required; none or several still emit nothing.
4609-
caller_file = file_of_node.get(caller)
4638+
# An inherited field's type is judged from the file declaring the field:
4639+
# `class Child extends Base` need not import what `Base` holds.
4640+
caller_file = file_of_node.get(declaring_nid or caller)
46104641
imported = imported_by_filenode.get(caller_file, set())
46114642

46124643
def _visible(nid: str) -> bool:

‎graphify/extractors/engine.py‎

Lines changed: 133 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2541,6 +2541,17 @@ def _optional_type_ident(type_annotation):
25412541
return _read_text(idents[0], source)
25422542
return None
25432543

2544+
def _class_name(class_body) -> str | None:
2545+
# `class Foo { .. }` -> "Foo"; an unnamed class expression -> None
2546+
owner = class_body.parent if class_body is not None and class_body.type == "class_body" else None
2547+
name = owner.child_by_field_name("name") if owner is not None else None
2548+
return _read_text(name, source) if name is not None else None
2549+
2550+
def _new_type(new_expr) -> str | None:
2551+
ctor = new_expr.child_by_field_name("constructor")
2552+
return _read_text(ctor, source) if ctor is not None and ctor.type in ("identifier", "type_identifier") else None
2553+
2554+
assigned: dict[str, dict[str, str]] = {}
25442555
stack = [root]
25452556
while stack:
25462557
n = stack.pop()
@@ -2570,28 +2581,132 @@ def _optional_type_ident(type_annotation):
25702581
# same as a constructor parameter property. It is recorded under its
25712582
# own class only, so two classes in one file with a same-named field
25722583
# of different types never borrow each other's type. Static fields are
2573-
# read as `Cls.field`, not `this.field`, so they are skipped.
2584+
# kept apart: only a static method reads them as `this.field`.
25742585
name_n = n.child_by_field_name("name") or n.child_by_field_name("property")
2575-
owner = n.parent.parent if n.parent is not None else None
2576-
owner_name = owner.child_by_field_name("name") if owner is not None else None
2577-
if (name_n is not None and owner_name is not None
2578-
and name_n.type in ("property_identifier", "private_property_identifier")
2579-
and not any(c.type == "static" for c in n.children)):
2586+
owner_name = _class_name(n.parent)
2587+
if owner_name is not None and any(c.type == "static" for c in n.children):
2588+
owner_name += _TS_STATIC_SUFFIX
2589+
if (name_n is not None
2590+
and name_n.type in ("property_identifier", "private_property_identifier")):
25802591
ann = n.child_by_field_name("type")
25812592
value = n.child_by_field_name("value")
25822593
tname = None
25832594
if ann is not None:
25842595
tname = _bare_type_ident(ann) or _optional_type_ident(ann)
25852596
elif value is not None and value.type == "new_expression":
2586-
ctor = value.child_by_field_name("constructor")
2587-
if ctor is not None and ctor.type in ("identifier", "type_identifier"):
2588-
tname = _read_text(ctor, source)
2597+
tname = _new_type(value)
25892598
name = _read_text(name_n, source)
2590-
fields = field_table.setdefault(_read_text(owner_name, source), {})
2591-
if name and tname and name not in fields:
2592-
fields[name] = tname
2599+
if name and tname:
2600+
if owner_name is not None:
2601+
field_table.setdefault(owner_name, {}).setdefault(name, tname)
2602+
elif name not in table:
2603+
# An unnamed class (`export default class { .. }`) has no
2604+
# node to key its fields by, and its methods have no class
2605+
# either: keep the file table they read.
2606+
table[name] = tname
2607+
elif t == "method_definition" and field_table is not None:
2608+
# The constructor declares fields too: parameter properties
2609+
# (`constructor(private repo: Repo)`) and `this.repo = repo` /
2610+
# `this.repo = new Repo()`. A declared field type wins over an
2611+
# assignment (merged after the walk).
2612+
mname = n.child_by_field_name("name")
2613+
owner_name = _class_name(n.parent)
2614+
accessor = next((c.type for c in n.children if c.type in ("get", "set")), None)
2615+
if mname is not None and owner_name is not None and accessor is not None:
2616+
# `get repo(): Repo` / `set repo(r: Repo)` read and write a
2617+
# `this.repo` of that type, like a field.
2618+
tname = None
2619+
if accessor == "get":
2620+
ret = n.child_by_field_name("return_type")
2621+
tname = (_bare_type_ident(ret) or _optional_type_ident(ret)) if ret is not None else None
2622+
else:
2623+
params = n.child_by_field_name("parameters")
2624+
first = next((p for p in params.named_children if p.type in ("required_parameter", "optional_parameter")), None) if params is not None else None
2625+
ann = first.child_by_field_name("type") if first is not None else None
2626+
tname = (_bare_type_ident(ann) or _optional_type_ident(ann)) if ann is not None else None
2627+
if tname:
2628+
field_table.setdefault(owner_name, {}).setdefault(_read_text(mname, source), tname)
2629+
if (mname is not None and owner_name is not None
2630+
and _read_text(mname, source) == "constructor"):
2631+
params = n.child_by_field_name("parameters")
2632+
typed: dict[str, str] = {}
2633+
for p in params.named_children if params is not None else ():
2634+
if p.type not in ("required_parameter", "optional_parameter"):
2635+
continue
2636+
pat, ann = p.child_by_field_name("pattern"), p.child_by_field_name("type")
2637+
if pat is None or pat.type != "identifier" or ann is None:
2638+
continue
2639+
tname = _bare_type_ident(ann) or _optional_type_ident(ann)
2640+
if not tname:
2641+
continue
2642+
pname = _read_text(pat, source)
2643+
typed[pname] = tname
2644+
if any(c.type in ("accessibility_modifier", "readonly") for c in p.children):
2645+
field_table.setdefault(owner_name, {}).setdefault(pname, tname)
2646+
body = n.child_by_field_name("body")
2647+
for stmt in body.named_children if body is not None else ():
2648+
expr = stmt.named_children[0] if stmt.type == "expression_statement" and stmt.named_children else None
2649+
if expr is None or expr.type != "assignment_expression":
2650+
continue
2651+
left, right = expr.child_by_field_name("left"), expr.child_by_field_name("right")
2652+
if left is None or right is None or left.type != "member_expression":
2653+
continue
2654+
obj, prop = left.child_by_field_name("object"), left.child_by_field_name("property")
2655+
if obj is None or obj.type != "this" or prop is None:
2656+
continue
2657+
tname = (typed.get(_read_text(right, source)) if right.type == "identifier"
2658+
else _new_type(right) if right.type == "new_expression" else None)
2659+
if tname:
2660+
assigned.setdefault(owner_name, {}).setdefault(_read_text(prop, source), tname)
25932661
for c in n.children:
25942662
stack.append(c)
2663+
if field_table is not None:
2664+
for cls, fields in assigned.items():
2665+
for name, tname in fields.items():
2666+
field_table.setdefault(cls, {}).setdefault(name, tname)
2667+
2668+
_TS_STATIC_SUFFIX = "\x00static"
2669+
2670+
2671+
def _ts_attach_class_fields(result: dict, field_table: dict[str, dict[str, str]], str_path: str) -> None:
2672+
"""Put each class's field types on its node as `_ts_fields`.
2673+
2674+
Node ids are casefolded, so `class UsecaseVisitor` and `const usecaseVisitor`
2675+
in one file are one node, labeled after either name; classes are matched the
2676+
same way, and two classes folding together keep only the fields they type
2677+
alike."""
2678+
def _fold(label: str) -> str:
2679+
return "".join(c for c in str(label) if c.isascii() and c.isalnum()).lower()
2680+
2681+
by_key: dict[tuple[str, bool], dict[str, str]] = {}
2682+
for cls, fields in field_table.items():
2683+
static = cls.endswith(_TS_STATIC_SUFFIX)
2684+
merged = by_key.setdefault((_fold(cls.removesuffix(_TS_STATIC_SUFFIX)), static), {})
2685+
for name, tname in fields.items():
2686+
merged[name] = tname if merged.get(name, tname) == tname else ""
2687+
if not by_key:
2688+
return
2689+
for node in result.get("nodes", []):
2690+
if node.get("source_file") != str_path:
2691+
continue
2692+
for static, attr in ((False, "_ts_fields"), (True, "_ts_static_fields")):
2693+
fields = by_key.get((_fold(node.get("label", "")), static))
2694+
kept = {k: v for k, v in sorted((fields or {}).items()) if v}
2695+
if kept:
2696+
node[attr] = kept
2697+
2698+
2699+
def _ts_in_static_method(node) -> bool:
2700+
"""Whether ``node`` runs in a `static` method, where `this` is the class.
2701+
Arrow functions keep the enclosing `this`; other functions end the search."""
2702+
while node is not None:
2703+
if node.type == "method_definition":
2704+
return any(c.type == "static" for c in node.children)
2705+
if node.type in _TS_FUNCTION_NODES and node.type != "arrow_function":
2706+
return False
2707+
node = node.parent
2708+
return False
2709+
25952710

25962711
_TS_FUNCTION_NODES = frozenset({
25972712
"arrow_function", "method_definition", "function_declaration", "function_expression",
@@ -8229,6 +8344,8 @@ def walk_calls(
82298344
"tree_sitter_javascript", "tree_sitter_typescript",
82308345
):
82318346
rc_entry["ts_this_field"] = True
8347+
if _ts_in_static_method(node):
8348+
rc_entry["ts_static_this"] = True
82328349
# Tag the C++ raw_call's language so the cross-file C++ resolver
82338350
# claims it unambiguously: a `.h` file routes to extract_cpp or
82348351
# extract_objc by content, and both resolvers see `.h` in their
@@ -8750,14 +8867,13 @@ def _scan_js_module_dispatch(n) -> None:
87508867
# a call on a typed param (incl. inside a closure) resolve (#1630). The
87518868
# constructor-injection entries are populated during the walk above and win on
87528869
# a name clash (first-binding-wins in the helper).
8753-
# Class fields go to their own per-class table (`this.x` only reads the
8754-
# caller's class), never into the file-wide name table.
8870+
# Class fields go on their class node (`_ts_fields`), never into the
8871+
# file-wide name table: `this.x` reads the caller's class and its bases, which
8872+
# may live in files a changed-files rebuild does not re-extract.
87558873
ts_field_types: dict[str, dict[str, str]] = {}
87568874
if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
87578875
_ts_receiver_type_table(root, source, type_table, ts_field_types)
8758-
ts_field_types = {cls: f for cls, f in ts_field_types.items() if f}
8759-
if ts_field_types and not type_table:
8760-
result["ts_type_table"] = {"path": str_path, "table": {}, "fields": ts_field_types}
8876+
_ts_attach_class_fields(result, ts_field_types, str_path)
87618877
if config.ts_module == "tree_sitter_swift":
87628878
if type_table or swift_factory_bindings:
87638879
result["swift_type_table"] = {"path": str_path, "table": type_table}
@@ -8769,8 +8885,6 @@ def _scan_js_module_dispatch(n) -> None:
87698885
elif type_table:
87708886
if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
87718887
result["ts_type_table"] = {"path": str_path, "table": type_table}
8772-
if ts_field_types:
8773-
result["ts_type_table"]["fields"] = ts_field_types
87748888
elif config.ts_module == "tree_sitter_cpp":
87758889
result["cpp_type_table"] = {"path": str_path, "table": type_table}
87768890
return result

0 commit comments

Comments
 (0)