From 210009804249f552db599e602d5bb2fca716bedd Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Thu, 1 Oct 2026 10:16:11 +0530 Subject: [PATCH] fix(typescript): extract abstract method signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An abstract class's `abstract area(): number;` parses as `abstract_method_signature`, which was not a TypeScript function type, so the abstract method was dropped. An interface's `method_signature` has long been a function type, so `interface D { draw(): void }` put `draw` in the graph — but the identical contract on an `abstract class` did not. The result: the base of an abstract class kept only its concrete methods, a polymorphic `shape.area()` had no declaration to resolve to, and the overridden contract had no node to carry it. An abstract method is the same kind of contract as an interface method: a subclass must implement it. Add `abstract_method_signature` to the TS function types (inherited by the TSX config). Its name sits on the same `name` field as `method_signature`, so no other change is needed. Abstract fields (`public_field_definition`) are left as-is, matching concrete fields which are not nodes either. Added tests/test_ts_abstract_methods.py: the abstract method becomes a node, a call through the abstract type resolves to it, and it reaches parity with the interface method it mirrors. --- graphify/extract.py | 7 +- tests/test_ts_abstract_methods.py | 102 ++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/test_ts_abstract_methods.py diff --git a/graphify/extract.py b/graphify/extract.py index b54889c8b..1a6481e0e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1266,7 +1266,12 @@ _TS_CONFIG = LanguageConfig( "enum_declaration", # named enums "type_alias_declaration", # named type aliases }), - function_types=frozenset({"function_declaration", "generator_function_declaration", "method_definition", "method_signature"}), + # `abstract_method_signature`: an abstract class's method is a contract + # declaration exactly like an `interface`'s `method_signature` (already + # captured) — a subclass must implement it. Without it the abstract method + # was dropped, so the base of an `abstract class` sat in the graph with only + # its concrete methods and no node for the overridden contract. + function_types=frozenset({"function_declaration", "generator_function_declaration", "method_definition", "method_signature", "abstract_method_signature"}), import_types=frozenset({"import_statement", "export_statement"}), call_types=frozenset({"call_expression", "new_expression"}), call_function_field="function", diff --git a/tests/test_ts_abstract_methods.py b/tests/test_ts_abstract_methods.py new file mode 100644 index 000000000..9e51833f2 --- /dev/null +++ b/tests/test_ts_abstract_methods.py @@ -0,0 +1,102 @@ +"""An `abstract` method of an abstract class is a node, like an interface method. + +`method_signature` (an interface's method) has been a TypeScript function type +for a while, so `interface Drawable { draw(): void }` puts `draw` in the graph. +An abstract class's `abstract area(): number;` parses as a DIFFERENT node, +`abstract_method_signature`, which was not a function type — so the abstract +method was dropped. The base of an `abstract class` kept only its concrete +methods, and a polymorphic `shape.area()` had no declaration to resolve to and +no node to carry the overridden contract. + +An abstract method is the same kind of contract as an interface method: a +subclass must implement it. These specs pin that the declaration becomes a node +and that calls through it resolve, matching the interface behaviour. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]) -> dict: + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + return extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp())) + finally: + os.chdir(old) + + +def _method_edge(r: dict, owner: str, method: str) -> bool: + labels = {n["id"]: str(n.get("label", "")) for n in r["nodes"]} + return any( + e.get("relation") == "method" + and labels.get(e.get("source")) == owner + and labels.get(e.get("target")) == method + for e in r["edges"] + ) + + +def _calls(r: dict, src: str, tgt: str) -> bool: + labels = {n["id"]: str(n.get("label", "")) for n in r["nodes"]} + return any( + e.get("relation") == "calls" + and labels.get(e.get("source")) == src + and labels.get(e.get("target")) == tgt + for e in r["edges"] + ) + + +def test_abstract_method_becomes_a_node(tmp_path): + r = _extract(tmp_path, {"s.ts": ( + "abstract class Shape {\n" + " abstract area(): number;\n" + "}\n" + )}) + assert _method_edge(r, "Shape", ".area()") + + +def test_abstract_and_concrete_methods_both_present(tmp_path): + r = _extract(tmp_path, {"s.ts": ( + "abstract class Shape {\n" + " abstract area(): number;\n" + " describe(): string { return this.area().toString(); }\n" + "}\n" + )}) + assert _method_edge(r, "Shape", ".area()") + assert _method_edge(r, "Shape", ".describe()") + # The concrete method that calls the abstract one links to it. + assert _calls(r, ".describe()", ".area()") + + +def test_call_through_abstract_type_resolves_to_the_declaration(tmp_path): + r = _extract(tmp_path, {"s.ts": ( + "abstract class Shape {\n" + " abstract area(): number;\n" + "}\n" + "function use(s: Shape): number {\n" + " return s.area();\n" + "}\n" + )}) + assert _calls(r, "use()", ".area()") + + +def test_abstract_method_parity_with_interface_method(tmp_path): + # Same shape via an interface already worked; the abstract class must match. + r = _extract(tmp_path, {"s.ts": ( + "interface Drawable {\n" + " draw(): void;\n" + "}\n" + "abstract class Widget {\n" + " abstract draw(): void;\n" + "}\n" + )}) + assert _method_edge(r, "Drawable", ".draw()") + assert _method_edge(r, "Widget", ".draw()")