Skip to content

Commit 8abbd7f

Browse files
authored
feat(typescript): add tsc_only toggle and surface synthesized anonymous callables (#179)
* feat(typescript): add tsc_only toggle and surface synthesized anonymous callables Drop reliance on the obsolete `--call-graph-provider both`. Add a configurable `tsc_only` knob on the TypeScript codeanalyzer config that passes `--tsc-only` (codeanalyzer-typescript >= 0.4.2) to pin the resolver-based call graph. Materialize Jelly-resolved anonymous callbacks as `TSSynthesizedCallable` so anonymous call-graph edges don't dangle. Wire `get_synthesized_callables` through the in-process, Neo4j, and facade backends, and add the nodes to the NetworkX call graph. Bump codeanalyzer-typescript 0.4.0 -> 0.4.3. Closes #174 * docs: clarify project_path is optional (and only validated when passed) on the Neo4j backend The existence check is keyed off the path, not the backend: any non-None project_path is resolved and must be a directory on every backend, while None skips validation entirely (the Neo4j backends read their graph out of band over Bolt). Spell this out in `_normalize_project_path`, the java/python/typescript factory docstrings, and the README Neo4j example.
1 parent 0d85b2f commit 8abbd7f

11 files changed

Lines changed: 118 additions & 13 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ analysis = CLDK.python(
114114
classes = analysis.get_all_classes()
115115
```
116116

117+
> **`project_path` with the Neo4j backend:** it's **optional** — the graph is read over Bolt, so you can omit it as shown above. CLDK validates `project_path` only when you actually pass one (it must exist and be a directory, on every backend); passing `None` skips that check. Supply a real path only if you also need on-disk source access (e.g. file content/snippets) alongside the graph.
118+
117119
> **Deprecation:** the old `CLDK(language="java").analysis(...)` entry point still works as a thin compatibility shim (it emits a `DeprecationWarning`). Prefer the `CLDK.java()` / `CLDK.python()` / `CLDK.typescript()` factory methods.
118120
119121
## Supported Languages & Backends

cldk/analysis/commons/backend_config.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@ class PyCodeAnalyzerConfig(CodeAnalyzerConfig):
7575
use_ray: bool = False
7676

7777

78+
@dataclass
79+
class TSCodeAnalyzerConfig(CodeAnalyzerConfig):
80+
"""Select the in-process codeanalyzer backend for TypeScript.
81+
82+
Adds the TypeScript-only call-graph knob on top of :class:`CodeAnalyzerConfig`.
83+
84+
Attributes:
85+
tsc_only: If ``True``, restrict the analyzer to the tsc resolver call graph by passing
86+
``--tsc-only`` (codeanalyzer-typescript >= 0.4.2). Defaults to ``False`` (let the
87+
binary choose its default). This is the supported replacement for the obsolete
88+
``--call-graph-provider both``.
89+
"""
90+
91+
tsc_only: bool = False
92+
93+
7894
@dataclass
7995
class Neo4jConnectionConfig:
8096
"""Select the read-only Neo4j-backed analysis backend.
@@ -102,7 +118,7 @@ class Neo4jConnectionConfig:
102118
# Per-language discriminated unions the facades match on.
103119
JavaBackend = Union[CodeAnalyzerConfig, Neo4jConnectionConfig]
104120
PyBackend = Union[PyCodeAnalyzerConfig, Neo4jConnectionConfig]
105-
TSBackend = Union[CodeAnalyzerConfig, Neo4jConnectionConfig]
121+
TSBackend = Union[TSCodeAnalyzerConfig, CodeAnalyzerConfig, Neo4jConnectionConfig]
106122

107123

108124
def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path, None], language: str) -> Path | None:

cldk/analysis/typescript/backend.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
TSImport,
5656
TSInterface,
5757
TSModule,
58+
TSSynthesizedCallable,
5859
TSTypeAlias,
5960
TSVariableDeclaration,
6061
)
@@ -86,6 +87,11 @@ def get_modules(self) -> List[TSModule]:
8687
def get_external_symbols(self) -> Dict[str, TSExternalSymbol]:
8788
"""Phantom (external) call targets — imported/required library members."""
8889

90+
@abstractmethod
91+
def get_synthesized_callables(self) -> Dict[str, TSSynthesizedCallable]:
92+
"""Anonymous-callback endpoints the symbol table never names (Jelly-resolved). Keyed by the
93+
synthesized signature that ``call_graph`` edges reference. Empty for the ``tsc`` resolver."""
94+
8995
@abstractmethod
9096
def get_typescript_file(self, qualified_name: str) -> str | None:
9197
"""The file path declaring the symbol with the given signature."""

cldk/analysis/typescript/codeanalyzer/codeanalyzer.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
TSInterface,
5555
TSModule,
5656
TSNamespace,
57+
TSSynthesizedCallable,
5758
TSTypeAlias,
5859
TSVariableDeclaration,
5960
)
@@ -81,6 +82,9 @@ class hierarchy, call sites, entrypoints, decorators, ...). The :class:`TypeScri
8182
analysis_level: ``AnalysisLevel.symbol_table`` (1) or ``AnalysisLevel.call_graph`` (2).
8283
eager_analysis: If True, re-run the analyzer even if a cached ``analysis.json`` exists.
8384
target_files: Restrict analysis to these files (incremental).
85+
tsc_only: If True, restrict the analyzer to the tsc resolver call graph by passing
86+
``--tsc-only`` (codeanalyzer-typescript >= 0.4.2). Defaults to False (let the binary
87+
choose its default). Replaces reliance on the obsolete ``--call-graph-provider both``.
8488
"""
8589

8690
def __init__(
@@ -90,12 +94,14 @@ def __init__(
9094
analysis_level: str,
9195
eager_analysis: bool,
9296
target_files: List[str] | None,
97+
tsc_only: bool = False,
9398
) -> None:
9499
self.project_dir = project_dir
95100
self.analysis_json_path = analysis_json_path
96101
self.analysis_level = analysis_level
97102
self.eager_analysis = eager_analysis
98103
self.target_files = target_files
104+
self.tsc_only = tsc_only
99105
self.application: TSApplication = self._init_codeanalyzer(
100106
analysis_level=1 if analysis_level == AnalysisLevel.symbol_table else 2
101107
)
@@ -139,6 +145,11 @@ def _init_codeanalyzer(self, analysis_level: int = 1) -> TSApplication:
139145
if self.target_files:
140146
for tf in self.target_files:
141147
target_args += ["-t", str(tf).strip()]
148+
# Restrict the call graph to the tsc resolver path when requested, replacing the obsolete
149+
# `--call-graph-provider both`. The `--tsc-only` flag lands in codeanalyzer-typescript
150+
# 0.4.2; older binaries reject it, so only opt in when running >= 0.4.2.
151+
if self.tsc_only:
152+
target_args += ["--tsc-only"]
142153

143154
if self.analysis_json_path is None:
144155
# Read compact JSON from the stdout pipe.
@@ -285,6 +296,11 @@ def get_modules(self) -> List[TSModule]:
285296
def get_external_symbols(self) -> Dict[str, TSExternalSymbol]:
286297
return self.application.external_symbols
287298

299+
def get_synthesized_callables(self) -> Dict[str, TSSynthesizedCallable]:
300+
"""Anonymous-callback endpoints Jelly resolves that the symbol table never names. Keyed by
301+
the synthesized signature an edge's ``source``/``target`` references."""
302+
return self.application.synthesized_callables
303+
288304
def get_typescript_file(self, qualified_name: str) -> str | None:
289305
return self._file_of.get(qualified_name)
290306

@@ -293,8 +309,9 @@ def get_typescript_module(self, file_path: str) -> TSModule | None:
293309

294310
# -----[ call graph ]-----
295311
def get_call_graph(self) -> nx.DiGraph:
296-
"""Build (and cache) a NetworkX DiGraph whose nodes are callable signatures (and phantom
297-
external symbols) and whose edges are the identity-only call edges."""
312+
"""Build (and cache) a NetworkX DiGraph whose nodes are callable signatures (plus phantom
313+
external symbols and synthesized anonymous callbacks) and whose edges are the identity-only
314+
call edges."""
298315
if self._call_graph is not None:
299316
return self._call_graph
300317
graph = nx.DiGraph()
@@ -303,6 +320,9 @@ def get_call_graph(self) -> nx.DiGraph:
303320
# Phantom (external) nodes so that import-attributed edges don't dangle.
304321
for sig, ext in self.application.external_symbols.items():
305322
graph.add_node(sig, external=True, module=ext.module, name=ext.name)
323+
# Synthesized anonymous-callback nodes so Jelly's anonymous edges don't dangle.
324+
for sig, syn in self.application.synthesized_callables.items():
325+
graph.add_node(sig, external=False, synthesized=True, name=syn.name, path=syn.path)
306326
for edge in self.application.call_graph:
307327
graph.add_edge(
308328
edge.source,

cldk/analysis/typescript/neo4j/neo4j_backend.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
TSImport,
7676
TSInterface,
7777
TSModule,
78+
TSSynthesizedCallable,
7879
TSTypeAlias,
7980
TSVariableDeclaration,
8081
)
@@ -238,6 +239,7 @@ def get_application(self) -> TSApplication:
238239
symbol_table=self.get_symbol_table(),
239240
call_graph=self._call_edges(),
240241
external_symbols=self.get_external_symbols(),
242+
synthesized_callables=self.get_synthesized_callables(),
241243
)
242244

243245
def get_symbol_table(self) -> Dict[str, TSModule]:
@@ -262,6 +264,15 @@ def get_external_symbols(self) -> Dict[str, TSExternalSymbol]:
262264
)
263265
return {r["p"]["signature"]: R.external(r["p"]) for r in rows}
264266

267+
def get_synthesized_callables(self) -> Dict[str, TSSynthesizedCallable]:
268+
"""Anonymous-callback endpoints minted as ``:AnonymousCallable`` nodes (keyed by signature),
269+
scoped to this application's modules."""
270+
rows = self._run(
271+
"MATCH (a:AnonymousCallable) WHERE a._module IN $mods RETURN DISTINCT properties(a) AS p",
272+
mods=self._modules,
273+
)
274+
return {r["p"]["signature"]: R.synthesized(r["p"]) for r in rows}
275+
265276
def get_typescript_file(self, qualified_name: str) -> str | None:
266277
rows = self._run(
267278
"MATCH (s:Symbol {signature: $sig}) WHERE s._module IN $mods RETURN s._module AS m LIMIT 1",

cldk/analysis/typescript/neo4j/reconstruct.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
TSModule,
5454
TSNamespace,
5555
TSSymbol,
56+
TSSynthesizedCallable,
5657
TSTypeAlias,
5758
TSTypeParameter,
5859
TSVariableDeclaration,
@@ -180,6 +181,15 @@ def external(props: Props) -> TSExternalSymbol:
180181
)
181182

182183

184+
def synthesized(props: Props) -> TSSynthesizedCallable:
185+
return TSSynthesizedCallable(
186+
name=props.get("name", "<anonymous>"),
187+
path=props.get("path", ""),
188+
start_line=props.get("start_line", -1),
189+
start_column=props.get("start_column", -1),
190+
)
191+
192+
183193
# ----------------------------------------------------------------------------------------------
184194
# declaration nodes (children supplied by the backend)
185195
# ----------------------------------------------------------------------------------------------

cldk/analysis/typescript/typescript_analysis.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
TSImport,
4848
TSInterface,
4949
TSModule,
50+
TSSynthesizedCallable,
5051
TSTypeAlias,
5152
TSVariableDeclaration,
5253
)
@@ -102,6 +103,7 @@ def __init__(
102103
analysis_level=analysis_level,
103104
eager_analysis=eager_analysis,
104105
target_files=target_files,
106+
tsc_only=getattr(self.backend_config, "tsc_only", False),
105107
)
106108
self.application: TSApplication = self.backend.get_application()
107109

@@ -126,6 +128,12 @@ def get_external_symbols(self) -> Dict[str, TSExternalSymbol]:
126128
reachability."""
127129
return self.backend.get_external_symbols()
128130

131+
def get_synthesized_callables(self) -> Dict[str, TSSynthesizedCallable]:
132+
"""The synthesized anonymous-callback endpoints the call graph points at — Jelly-resolved
133+
callbacks the symbol table never names (keyed by their ``<host>:<line:col>`` signature).
134+
Empty under the ``tsc``-only resolver. Materialized so anonymous call edges don't dangle."""
135+
return self.backend.get_synthesized_callables()
136+
129137
def get_call_graph_json(self) -> str:
130138
return self.backend.get_call_graph_json()
131139

cldk/core.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
PyBackend,
5656
PyCodeAnalyzerConfig,
5757
TSBackend,
58+
TSCodeAnalyzerConfig,
5859
)
5960
from cldk.analysis.commons.treesitter import TreesitterJava
6061
from cldk.analysis.python.python_analysis import PythonAnalysis
@@ -66,10 +67,13 @@
6667

6768

6869
def _normalize_project_path(project_path: str | Path | None) -> Path | None:
69-
"""Expand and resolve a project path, validating it is a directory.
70+
"""Expand, resolve, and validate a project path.
7071
71-
Returns ``None`` unchanged (the Neo4j backends read their graph out of band, so a project
72-
directory is optional there).
72+
Validation is keyed off the *path*, not the backend: any non-``None`` path is resolved and
73+
must exist and be a directory, otherwise :class:`CldkInitializationException` is raised — this
74+
holds on every backend, including Neo4j. ``None`` is returned unchanged and skips validation
75+
entirely, because the Neo4j backends read their graph out of band (over Bolt), so a project
76+
directory is optional there.
7377
"""
7478
if project_path is None:
7579
return None
@@ -134,7 +138,10 @@ def java(
134138
"""Create a Java analysis facade.
135139
136140
Args:
137-
project_path: Path to the Java project directory.
141+
project_path: Path to the Java project directory. Optional only when ``backend`` is a
142+
:class:`Neo4jConnectionConfig` (the graph is read out of band over Bolt). When
143+
provided, the path is validated — it must exist and be a directory — regardless of
144+
backend.
138145
source_code: Single Java source string (deprecated; pass ``project_path`` instead).
139146
analysis_level: Analysis depth (see :class:`~cldk.analysis.AnalysisLevel`).
140147
target_files: Restrict analysis to these files.
@@ -181,7 +188,9 @@ def python(
181188
182189
Args:
183190
project_path: Path to the Python project directory. Optional only when ``backend`` is a
184-
:class:`Neo4jConnectionConfig` (the graph is populated out of band).
191+
:class:`Neo4jConnectionConfig` (the graph is populated out of band over Bolt). When
192+
provided, the path is validated — it must exist and be a directory — regardless of
193+
backend.
185194
analysis_level: Analysis depth (see :class:`~cldk.analysis.AnalysisLevel`).
186195
target_files: Restrict analysis to these files.
187196
eager: Force regeneration of cached analysis.
@@ -209,12 +218,16 @@ def typescript(
209218
210219
Args:
211220
project_path: Path to the TypeScript project directory. Optional only when ``backend``
212-
is a :class:`Neo4jConnectionConfig` (the graph is populated out of band).
221+
is a :class:`Neo4jConnectionConfig` (the graph is populated out of band over Bolt).
222+
When provided, the path is validated — it must exist and be a directory — regardless
223+
of backend.
213224
analysis_level: Analysis depth (see :class:`~cldk.analysis.AnalysisLevel`).
214225
target_files: Restrict analysis to these files.
215226
eager: Force regeneration of cached analysis.
216-
backend: Backend configuration. Defaults to :class:`CodeAnalyzerConfig`;
217-
pass a :class:`Neo4jConnectionConfig` to use the read-only Neo4j backend.
227+
backend: Backend configuration. Defaults to :class:`CodeAnalyzerConfig`; pass a
228+
:class:`TSCodeAnalyzerConfig` to set TypeScript-only knobs such as ``tsc_only``
229+
(passes ``--tsc-only``), or a :class:`Neo4jConnectionConfig` to use the read-only
230+
Neo4j backend.
218231
"""
219232
return TypeScriptAnalysis(
220233
project_dir=_normalize_project_path(project_path),

cldk/models/typescript/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
TSNamespace,
3838
TSOverloadSignature,
3939
TSSymbol,
40+
TSSynthesizedCallable,
4041
TSTypeAlias,
4142
TSTypeParameter,
4243
TSVariableDeclaration,
@@ -63,6 +64,7 @@
6364
"TSNamespace",
6465
"TSOverloadSignature",
6566
"TSSymbol",
67+
"TSSynthesizedCallable",
6668
"TSTypeAlias",
6769
"TSTypeParameter",
6870
"TSVariableDeclaration",

cldk/models/typescript/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,22 @@ class TSExternalSymbol(_Base):
415415
module: str
416416

417417

418+
class TSSynthesizedCallable(_Base):
419+
"""A first-party anonymous callback that Jelly resolves as a call-graph endpoint but the symbol
420+
table never names (the canonicalizer returns ``null`` for anonymous functions). Materialized so
421+
that ``call_graph`` edges to anonymous callbacks don't dangle.
422+
423+
Slim, like :class:`TSExternalSymbol`: the map key in ``TSApplication.synthesized_callables`` IS
424+
the synthesized signature ``<nearest-named-enclosing-signature>:<line:col>``, so an edge's
425+
``source``/``target`` byte-matches it just like a real ``TSCallable.signature``. The ``tsc``
426+
resolver emits an empty map; Jelly (the union default) populates it."""
427+
428+
name: str # display name — always "<anonymous>"; the signature key carries the precise identity
429+
path: str # owning module key (project-relative POSIX path WITH extension)
430+
start_line: int
431+
start_column: int
432+
433+
418434
class TSEntrypoint(_Base):
419435
"""A framework entrypoint (populated by level-2 finders; empty for level 1). Embedded on the
420436
owning ``TSCallable``/``TSClass``, so it carries no signature/source_file of its own."""
@@ -432,6 +448,7 @@ class TSApplication(_Base):
432448
symbol_table: Dict[str, TSModule]
433449
call_graph: List[TSCallEdge] = []
434450
external_symbols: Dict[str, TSExternalSymbol] = {}
451+
synthesized_callables: Dict[str, TSSynthesizedCallable] = {}
435452

436453

437454
# Resolve forward references for the mutually-recursive models.

0 commit comments

Comments
 (0)