mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
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:
1 parent
97f0dc197e
commit
18682e8dce
2 files changed
+92
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user