From 18682e8dce166f8cd18c836fb9c3cc3ccbe1de41 Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Thu, 8 Oct 2026 18:11:20 +0530 Subject: [PATCH] feat(ts): type `this.field` receivers from class field declarations The TS receiver table behind `this.repo.findById()` held constructor parameter properties, `new` locals and typed parameters, but no class fields, which is how most NestJS, Angular and TypeORM classes hold their collaborators (`protected repo: Repo`, `private logger = new Logger()`, `#root: Node`). Those calls got no edge. _ts_receiver_type_table now records non-static class fields: `name: T`, `name?: T`, `T | undefined` / `T | null`, an untyped `new T()` initializer, and `#private` names keyed as the extractor records the receiver. Arrays, generics and two-class unions stay untyped; the #2553 origin gate and first-binding-wins are unchanged. JS/TS files bypass the AST cache, so no schema change. Against the TypeScript compiler, 0.9.80 vs this change: NestJS this.x.method() 47.9% -> 77.0% (+102 edges), TypeORM 64.5% -> 79.6% (+91), all 193 confirmed at the call site, 0 lost; changed-files rebuild == full build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf (cherry picked from commit e5fb5e3b95edf30ff0165bbab596cf573494807b) --- graphify/extractors/engine.py | 33 ++++++++++++++ tests/test_ts_receiver_member_calls.py | 59 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 837dd367b..7097396e7 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2393,6 +2393,18 @@ def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None: return _read_text(idents[0], source) return None + def _optional_type_ident(type_annotation): + # `: T | undefined` / `: T | null`: an optional field still holds a T. + unions = [c for c in type_annotation.children if c.is_named] + if len(unions) != 1 or unions[0].type != "union_type": + return None + members = [c for c in unions[0].children if c.is_named] + idents = [c for c in members if c.type == "type_identifier"] + empties = [c for c in members if _read_text(c, source) in ("undefined", "null")] + if len(idents) == 1 and len(idents) + len(empties) == len(members): + return _read_text(idents[0], source) + return None + stack = [root] while stack: n = stack.pop() @@ -2416,6 +2428,27 @@ 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"): + # 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. + name_n = n.child_by_field_name("name") or n.child_by_field_name("property") + if (name_n 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") + value = n.child_by_field_name("value") + tname = None + if ann is not None: + tname = _bare_type_ident(ann) or _optional_type_ident(ann) + elif value is not None and value.type == "new_expression": + ctor = value.child_by_field_name("constructor") + 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 for c in n.children: stack.append(c) diff --git a/tests/test_ts_receiver_member_calls.py b/tests/test_ts_receiver_member_calls.py index 682f95b07..f02015d1e 100644 --- a/tests/test_ts_receiver_member_calls.py +++ b/tests/test_ts_receiver_member_calls.py @@ -155,3 +155,62 @@ def test_same_file_type_still_resolves(tmp_path): "export function runLocal(): void { r.save(); }\n"), }) assert any("runLocal" in s and "save" in t for s, t in calls) + + +# ── Class fields (`this.field.m()` where the field is declared on the class, not +# injected through the constructor). NestJS / Angular / TypeORM services hold their +# collaborators this way; only constructor parameter properties were typed before. + +def _caller_hits(calls, caller: str, callee: str = "doThing") -> bool: + return any(s == f".{caller}()" and callee in t for s, t in calls) + + +def test_typed_class_fields_type_this_field_calls(tmp_path): + calls, _ = _calls(tmp_path, { + "svc.ts": _SVC, + "k.ts": ('import { Svc } from "./svc";\n' + "export class K {\n" + " protected plain: Svc;\n" + " private maybe?: Svc | undefined;\n" + " #hidden: Svc = new Svc();\n" + " private made = new Svc();\n" + " declare readonly declared: Svc;\n" + " a(): number { return this.plain.doThing(); }\n" + " b(): number { return this.maybe?.doThing() ?? 0; }\n" + " c(): number { return this.#hidden.doThing(); }\n" + " d(): number { return this.made.doThing(); }\n" + " e(): number { return this.declared.doThing(); }\n" + "}\n"), + }) + for caller in ("a", "b", "c", "d", "e"): + assert _caller_hits(calls, caller), caller + + +def test_class_fields_the_table_cannot_type_emit_no_edge(tmp_path): + calls, _ = _calls(tmp_path, { + "svc.ts": _SVC, + "other.ts": "export class Other {\n doThing(): number { return 2; }\n}\n", + "k.ts": ('import { Svc } from "./svc";\nimport { Other } from "./other";\n' + "export class K {\n" + " static shared: Svc;\n" + " many: Svc[];\n" + " either: Svc | Other;\n" + " boxed: Map;\n" + " a(): number { return this.shared.doThing(); }\n" + " b(): number { return this.many.doThing(); }\n" + " c(): number { return this.either.doThing(); }\n" + " d(): number { return this.boxed.doThing(); }\n" + "}\n"), + }) + for caller in ("a", "b", "c", "d"): + assert not _caller_hits(calls, caller), caller + + +def test_class_field_of_an_unimported_type_emits_no_edge(tmp_path): + # The #2553 origin gate still applies: `Svc` here is not the local class. + calls, _ = _calls(tmp_path, { + "svc.ts": _SVC, + "k.ts": ('import type { Svc } from "external-pkg";\n' + "export class K {\n repo: Svc;\n a(): number { return this.repo.doThing(); }\n}\n"), + }) + assert not _caller_hits(calls, "a")