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")