Skip to content

Commit 16b7440

Browse files
committed
refactor(typescript): formalize the backend contract as a shared ABC
Extract TSAnalysisBackend (cldk/analysis/typescript/backend.py), an abstract base declaring the full 40-method query surface the TypeScriptAnalysis facade delegates to. Both backends now implement it: - TSCodeanalyzer (in-memory pydantic / NetworkX) - TSNeo4jBackend (Cypher over Neo4j) The facade<->backend relationship is now enforced by the type system and at instantiation time, instead of matching only by convention. Facade `backend` is typed against the ABC. Added a contract test asserting both backends subclass it, fully implement it, and preserve every method signature.
1 parent bcbcb71 commit 16b7440

5 files changed

Lines changed: 294 additions & 3 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
################################################################################
2+
# Copyright IBM Corporation 2026
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
################################################################################
16+
17+
"""The TypeScript analysis backend contract.
18+
19+
:class:`TypeScriptAnalysis` is a thin façade that delegates every query to a *backend*. Two
20+
interchangeable backends exist:
21+
22+
* :class:`~cldk.analysis.typescript.codeanalyzer.TSCodeanalyzer` — walks the in-memory pydantic
23+
``TSApplication`` / a NetworkX call graph built from ``analysis.json``;
24+
* :class:`~cldk.analysis.typescript.neo4j.TSNeo4jBackend` — answers the *same* queries with
25+
Cypher over the graph ``codeanalyzer-typescript`` emits with ``--emit neo4j``.
26+
27+
This ABC formalizes the surface those two share so the façade↔backend relationship is enforced by
28+
the type system (and at instantiation time) instead of matching only by convention. Both backends
29+
subclass it; the façade is typed against it. Backend-specific lifecycle (e.g. the Neo4j driver's
30+
``close()`` / context-manager support) is intentionally *not* part of the contract.
31+
32+
The vocabulary mirrors :class:`~cldk.analysis.java.codeanalyzer.JCodeanalyzer` /
33+
:class:`~cldk.analysis.python.codeanalyzer.PyCodeanalyzer`, but the node kinds are TypeScript-native
34+
(interfaces, type aliases, enums, namespaces, decorators, ...).
35+
"""
36+
37+
from __future__ import annotations
38+
39+
from abc import ABC, abstractmethod
40+
from typing import Dict, List, Set, Tuple
41+
42+
import networkx as nx
43+
44+
from cldk.models.typescript import (
45+
TSApplication,
46+
TSCallable,
47+
TSCallsite,
48+
TSClass,
49+
TSClassAttribute,
50+
TSDecorator,
51+
TSEnum,
52+
TSEnumMember,
53+
TSExport,
54+
TSExternalSymbol,
55+
TSImport,
56+
TSInterface,
57+
TSModule,
58+
TSTypeAlias,
59+
TSVariableDeclaration,
60+
)
61+
62+
63+
class TSAnalysisBackend(ABC):
64+
"""Abstract base every TypeScript analysis backend implements.
65+
66+
A backend owns *all* indexing and query logic for a TypeScript application; the
67+
:class:`TypeScriptAnalysis` façade is a one-line-delegation shim over it. Implementations must
68+
return the canonical ``cldk.models.typescript`` pydantic objects (or the documented
69+
NetworkX / dict / list shapes) so the two backends are behaviorally interchangeable.
70+
"""
71+
72+
# -----[ application / whole-program ]-----
73+
@abstractmethod
74+
def get_application(self) -> TSApplication:
75+
"""The whole application view (symbol table + call graph + external symbols)."""
76+
77+
@abstractmethod
78+
def get_symbol_table(self) -> Dict[str, TSModule]:
79+
"""The per-file symbol table, keyed by module file path."""
80+
81+
@abstractmethod
82+
def get_modules(self) -> List[TSModule]:
83+
"""All modules (compilation units)."""
84+
85+
@abstractmethod
86+
def get_external_symbols(self) -> Dict[str, TSExternalSymbol]:
87+
"""Phantom (external) call targets — imported/required library members."""
88+
89+
@abstractmethod
90+
def get_typescript_file(self, qualified_name: str) -> str | None:
91+
"""The file path declaring the symbol with the given signature."""
92+
93+
@abstractmethod
94+
def get_typescript_module(self, file_path: str) -> TSModule | None:
95+
"""The module for a file path."""
96+
97+
# -----[ call graph ]-----
98+
@abstractmethod
99+
def get_call_graph(self) -> nx.DiGraph:
100+
"""NetworkX DiGraph of callable signatures (and phantom external symbols) + call edges."""
101+
102+
@abstractmethod
103+
def get_call_graph_json(self) -> str:
104+
"""The application serialized as JSON."""
105+
106+
@abstractmethod
107+
def get_all_callers(self, target_class_name: str, target_method_declaration: str | None = None) -> Dict:
108+
"""Callers of a method, with the connecting call-graph edge metadata."""
109+
110+
@abstractmethod
111+
def get_all_callees(self, source_class_name: str, source_method_declaration: str | None = None) -> Dict:
112+
"""Callees of a method, with the connecting call-graph edge metadata."""
113+
114+
@abstractmethod
115+
def get_class_call_graph(self, qualified_class_name: str, method_signature: str | None = None) -> List[Tuple[str, str]]:
116+
"""Call-graph edges reachable from a class (or one of its methods)."""
117+
118+
@abstractmethod
119+
def get_class_hierarchy(self) -> nx.DiGraph:
120+
"""Inheritance/implementation graph: an edge child → base for every base class."""
121+
122+
# -----[ call sites ]-----
123+
@abstractmethod
124+
def get_call_sites(self, qualified_callable_name: str) -> List[TSCallsite]:
125+
"""The rich, syntactic call sites inside a callable."""
126+
127+
@abstractmethod
128+
def get_calling_lines(self, target_signature: str) -> List[int]:
129+
"""Sorted source lines anywhere in the project where ``target_signature`` is invoked."""
130+
131+
@abstractmethod
132+
def get_call_targets(self, source_signature: str) -> Set[str]:
133+
"""The call targets invoked from a callable, derived from its call sites."""
134+
135+
# -----[ classes / interfaces / enums / type-aliases ]-----
136+
@abstractmethod
137+
def get_all_classes(self) -> Dict[str, TSClass]:
138+
"""Every class, keyed by signature."""
139+
140+
@abstractmethod
141+
def get_class(self, qualified_class_name: str) -> TSClass | None:
142+
"""A single class by signature."""
143+
144+
@abstractmethod
145+
def get_all_interfaces(self) -> Dict[str, TSInterface]:
146+
"""Every interface, keyed by signature."""
147+
148+
@abstractmethod
149+
def get_all_enums(self) -> Dict[str, TSEnum]:
150+
"""Every enum, keyed by signature."""
151+
152+
@abstractmethod
153+
def get_enum_members(self, qualified_enum_name: str) -> List[TSEnumMember]:
154+
"""The members of an enum."""
155+
156+
@abstractmethod
157+
def get_all_type_aliases(self) -> Dict[str, TSTypeAlias]:
158+
"""Every type alias, keyed by signature."""
159+
160+
@abstractmethod
161+
def get_all_nested_classes(self, qualified_class_name: str) -> List[TSClass]:
162+
"""The classes declared inside a class."""
163+
164+
@abstractmethod
165+
def get_all_sub_classes(self, qualified_class_name: str) -> Dict[str, TSClass]:
166+
"""Classes that extend/implement the given class."""
167+
168+
@abstractmethod
169+
def get_extended_classes(self, qualified_class_name: str) -> List[str]:
170+
"""The base types a class extends (base classes minus implemented interfaces)."""
171+
172+
@abstractmethod
173+
def get_implemented_interfaces(self, qualified_class_name: str) -> List[str]:
174+
"""The interfaces a class implements."""
175+
176+
# -----[ methods / functions / fields ]-----
177+
@abstractmethod
178+
def get_all_methods_in_application(self) -> Dict[str, Dict[str, TSCallable]]:
179+
"""All methods grouped by their owning class/interface signature."""
180+
181+
@abstractmethod
182+
def get_all_methods_in_class(self, qualified_class_name: str) -> Dict[str, TSCallable]:
183+
"""The methods of a class/interface, keyed by short name."""
184+
185+
@abstractmethod
186+
def get_method(self, qualified_class_name: str, qualified_method_name: str) -> TSCallable | None:
187+
"""A single method of a class/interface."""
188+
189+
@abstractmethod
190+
def get_method_parameters(self, qualified_class_name: str, qualified_method_name: str) -> List[str]:
191+
"""The parameter names of a method."""
192+
193+
@abstractmethod
194+
def get_all_constructors(self, qualified_class_name: str) -> Dict[str, TSCallable]:
195+
"""The constructors of a class."""
196+
197+
@abstractmethod
198+
def get_all_functions(self) -> Dict[str, TSCallable]:
199+
"""Top-level (module/namespace) functions, keyed by signature."""
200+
201+
@abstractmethod
202+
def get_all_fields(self, qualified_class_name: str) -> List[TSClassAttribute]:
203+
"""The attributes/fields of a class."""
204+
205+
@abstractmethod
206+
def get_interface_properties(self, qualified_interface_name: str) -> List[TSClassAttribute]:
207+
"""The properties of an interface."""
208+
209+
# -----[ imports / exports / variables ]-----
210+
@abstractmethod
211+
def get_imports(self) -> Dict[str, List[TSImport]]:
212+
"""Per-file import bindings."""
213+
214+
@abstractmethod
215+
def get_all_exports(self) -> Dict[str, List[TSExport]]:
216+
"""Per-file export bindings."""
217+
218+
@abstractmethod
219+
def get_all_variables(self) -> Dict[str, List[TSVariableDeclaration]]:
220+
"""Per-file module-level variable declarations."""
221+
222+
# -----[ decorators ]-----
223+
@abstractmethod
224+
def get_decorators(self, qualified_callable_name: str) -> List[TSDecorator]:
225+
"""Structured decorators applied to a callable."""
226+
227+
@abstractmethod
228+
def get_class_decorators(self, qualified_class_name: str) -> List[TSDecorator]:
229+
"""Structured decorators applied to a class."""
230+
231+
@abstractmethod
232+
def get_methods_with_decorators(self, decorators: List[str]) -> Dict[str, List[str]]:
233+
"""Map each requested decorator name to the signatures of callables carrying it."""
234+
235+
@abstractmethod
236+
def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[str]]:
237+
"""Map each requested decorator name to the signatures of classes carrying it."""

cldk/analysis/typescript/codeanalyzer/codeanalyzer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import networkx as nx
4040

4141
from cldk.analysis import AnalysisLevel
42+
from cldk.analysis.typescript.backend import TSAnalysisBackend
4243
from cldk.models.typescript import (
4344
TSApplication,
4445
TSCallable,
@@ -62,7 +63,7 @@
6263
logger = logging.getLogger(__name__)
6364

6465

65-
class TSCodeanalyzer:
66+
class TSCodeanalyzer(TSAnalysisBackend):
6667
"""Build and query the application view of a TypeScript project by invoking the
6768
codeanalyzer-typescript binary as a subprocess.
6869

cldk/analysis/typescript/neo4j/neo4j_backend.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
import networkx as nx
6262

6363
from cldk.analysis import AnalysisLevel
64+
from cldk.analysis.typescript.backend import TSAnalysisBackend
6465
from cldk.analysis.typescript.neo4j import reconstruct as R
6566
from cldk.models.typescript import (
6667
TSApplication,
@@ -85,7 +86,7 @@
8586
logger = logging.getLogger(__name__)
8687

8788

88-
class TSNeo4jBackend:
89+
class TSNeo4jBackend(TSAnalysisBackend):
8990
"""Build and query the application view of a TypeScript project over Neo4j (Cypher).
9091
9192
Args:

cldk/analysis/typescript/typescript_analysis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import networkx as nx
3131

32+
from cldk.analysis.typescript.backend import TSAnalysisBackend
3233
from cldk.analysis.typescript.codeanalyzer import TSCodeanalyzer
3334
from cldk.analysis.typescript.neo4j import Neo4jConnectionConfig, TSNeo4jBackend
3435
from cldk.models.typescript import (
@@ -80,7 +81,7 @@ def __init__(
8081
self.target_files = target_files
8182
self.eager_analysis = eager_analysis
8283
self.neo4j_config = neo4j_config
83-
self.backend: TSCodeanalyzer | TSNeo4jBackend
84+
self.backend: TSAnalysisBackend
8485
if neo4j_config is not None:
8586
self.backend = TSNeo4jBackend(
8687
project_dir=project_dir,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
################################################################################
2+
# Copyright IBM Corporation 2026
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
################################################################################
16+
17+
"""The TypeScript backend contract: both backends implement the same ABC (no live Neo4j needed)."""
18+
19+
import inspect
20+
21+
import pytest
22+
23+
from cldk.analysis.typescript.backend import TSAnalysisBackend
24+
from cldk.analysis.typescript.codeanalyzer.codeanalyzer import TSCodeanalyzer
25+
from cldk.analysis.typescript.neo4j import TSNeo4jBackend
26+
27+
28+
def test_backends_subclass_the_contract():
29+
assert issubclass(TSCodeanalyzer, TSAnalysisBackend)
30+
assert issubclass(TSNeo4jBackend, TSAnalysisBackend)
31+
32+
33+
def test_contract_is_abstract():
34+
with pytest.raises(TypeError):
35+
TSAnalysisBackend()
36+
37+
38+
@pytest.mark.parametrize("backend", [TSCodeanalyzer, TSNeo4jBackend])
39+
def test_backends_fully_implement_the_contract(backend):
40+
# No abstract methods left unimplemented ⇒ the class is concrete/instantiable.
41+
assert backend.__abstractmethods__ == frozenset()
42+
43+
44+
@pytest.mark.parametrize("backend", [TSCodeanalyzer, TSNeo4jBackend])
45+
def test_signatures_match_the_contract(backend):
46+
"""Every abstract method's signature is preserved by each backend (params + defaults)."""
47+
for name, base_method in inspect.getmembers(TSAnalysisBackend, predicate=inspect.isfunction):
48+
if getattr(base_method, "__isabstractmethod__", False):
49+
base_sig = inspect.signature(base_method)
50+
impl_sig = inspect.signature(getattr(backend, name))
51+
assert impl_sig == base_sig, f"{backend.__name__}.{name} signature drifted: {impl_sig} != {base_sig}"

0 commit comments

Comments
 (0)