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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit e5fb5e3b95)
This commit is contained in:
rohit-jsfreaky authored and safishamsi committed 2026-10-08 14:27:06 +01:00
1 parent 97f0dc197e
commit 18682e8dce
2 files changed
+92

No files matched your search

+33
View File
@@ -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)
+59
View File
@@ -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<string, Svc>;\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")