Skip to content

Commit 47f4115

Browse files
committed
codeanalyzer ts now has jelly callgraph integration
Signed-off-by: Rahul Krishna <rkrsn@ibm.com>
1 parent 0c0b711 commit 47f4115

3 files changed

Lines changed: 129 additions & 14 deletions

File tree

cldk/analysis/typescript/typescript_analysis.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,9 @@ def get_entry_point_methods(self) -> Dict[str, Dict[str, TSCallable]]:
140140
"""Return methods identified as application entry points.
141141
142142
Not yet supported: the codeanalyzer-typescript backend's entrypoint detection is a stub
143-
placeholder — ``TSApplication.entrypoints`` and ``TSCallable.is_entrypoint`` are never
144-
populated — so this method exists for API parity with :class:`PythonAnalysis` /
145-
:class:`JavaAnalysis` but raises.
143+
placeholder — the ``entrypoints`` list on each ``TSCallable``/``TSClass`` is always empty
144+
(level-2 finders are not implemented) — so this method exists for API parity with
145+
:class:`PythonAnalysis` / :class:`JavaAnalysis` but raises.
146146
147147
Raises:
148148
NotImplementedError: Always.

cldk/models/typescript/models.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,7 @@ class TSCallable(_Base):
225225
inner_classes: Dict[str, "TSClass"] = {}
226226
local_variables: List[TSVariableDeclaration] = []
227227
cyclomatic_complexity: int = 0
228-
is_entrypoint: bool = False
229-
entrypoint_framework: Optional[str] = None
228+
entrypoints: List["TSEntrypoint"] = [] # non-empty ⇒ this callable is an entrypoint
230229
# TypeScript-native typed fields
231230
kind: str = "function" # function | method | constructor | getter | setter | arrow | function_expression
232231
accessibility: Optional[str] = None
@@ -265,6 +264,7 @@ class TSClass(_Base):
265264
methods: Dict[str, TSCallable] = {}
266265
attributes: Dict[str, TSClassAttribute] = {}
267266
inner_classes: Dict[str, "TSClass"] = {}
267+
entrypoints: List["TSEntrypoint"] = [] # class-level entrypoint (e.g. @Controller); empty otherwise
268268
is_abstract: bool = False
269269
is_exported: bool = False
270270
is_ambient: bool = False
@@ -406,24 +406,23 @@ class TSExternalSymbol(_Base):
406406
"""A WALA-style phantom node: a synthetic stub for a call target OUTSIDE the project (an
407407
imported/required library member). An edge's ``target`` byte-matches either a real
408408
``TSCallable.signature`` or a ``TSExternalSymbol.signature``, so the call graph stays
409-
dangling-free while still recording external (e.g. sink) calls."""
409+
dangling-free while still recording external (e.g. sink) calls.
410+
411+
Slim: the map key in ``TSApplication.external_symbols`` IS the signature (e.g.
412+
``commander.parse``), and membership already means external — so neither is repeated here."""
410413

411-
signature: str # e.g. "node:fs.readFileSync", "express.Router.get"
412414
name: str
413415
module: str
414-
kind: str = "unknown"
415-
is_external: bool = True
416416

417417

418418
class TSEntrypoint(_Base):
419-
"""A framework entrypoint (populated by level-2 finders; empty for level 1)."""
419+
"""A framework entrypoint (populated by level-2 finders; empty for level 1). Embedded on the
420+
owning ``TSCallable``/``TSClass``, so it carries no signature/source_file of its own."""
420421

421-
signature: str
422422
framework: str
423423
detection_source: str
424424
route_path: Optional[str] = None
425425
http_methods: List[str] = []
426-
source_file: Optional[str] = None
427426
tags: Dict[str, str] = {}
428427

429428

@@ -433,7 +432,6 @@ class TSApplication(_Base):
433432
symbol_table: Dict[str, TSModule]
434433
call_graph: List[TSCallEdge] = []
435434
external_symbols: Dict[str, TSExternalSymbol] = {}
436-
entrypoints: Dict[str, List[TSEntrypoint]] = {}
437435

438436

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

tests/analysis/typescript/test_typescript_analysis.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
"""Tests for the TypeScript analysis facade (backend subprocess mocked)."""
1818

19+
import json
1920
from unittest.mock import MagicMock, patch
2021

2122
import networkx as nx
@@ -64,7 +65,7 @@ def test_phantom_external_nodes(ts_analysis):
6465
ext = ts_analysis.get_external_symbols()
6566
assert "node:crypto.createHash" in ext
6667
assert ext["node:crypto.createHash"].module == "node:crypto"
67-
assert ext["node:crypto.createHash"].is_external is True
68+
assert ext["node:crypto.createHash"].name == "createHash"
6869
assert "node:path.extname" in ext
6970

7071
graph = ts_analysis.get_call_graph()
@@ -169,6 +170,46 @@ def test_exports_and_variables(ts_analysis):
169170
assert set(variables) == set(ts_analysis.get_symbol_table())
170171

171172

173+
def test_exports_and_variables_are_parsed(typescript_application, typescript_analysis_json, monkeypatch):
174+
"""Behavioral: a real export + module-level const must be parsed and surfaced (the slim
175+
fixture carries none, so inject them into the analyzed JSON)."""
176+
data = json.loads(typescript_analysis_json)
177+
models = data["symbol_table"]["src/models.ts"]
178+
models["exports"].append(
179+
{"name": "User", "module": None, "alias": None, "is_type_only": False, "export_kind": "named"}
180+
)
181+
models["variables"].append(
182+
{
183+
"name": "DEFAULT_ROLE",
184+
"type": "Role",
185+
"initializer": "Role.Member",
186+
"scope": "module",
187+
"declaration_kind": "const",
188+
"is_exported": True,
189+
}
190+
)
191+
192+
monkeypatch.setenv("CODEANALYZER_TS_BIN", "codeanalyzer-typescript")
193+
with patch("cldk.analysis.typescript.codeanalyzer.codeanalyzer.subprocess.run") as run_mock:
194+
run_mock.return_value = MagicMock(stdout=json.dumps(data), returncode=0)
195+
analysis = CLDK(language="typescript").analysis(
196+
project_path=typescript_application,
197+
analysis_backend_path=None,
198+
eager=True,
199+
analysis_level=AnalysisLevel.call_graph,
200+
)
201+
202+
exported = analysis.get_exports()["src/models.ts"]
203+
assert [e.name for e in exported] == ["User"]
204+
assert exported[0].export_kind == "named"
205+
206+
variables = analysis.get_variables()["src/models.ts"]
207+
assert [v.name for v in variables] == ["DEFAULT_ROLE"]
208+
assert variables[0].declaration_kind == "const"
209+
assert variables[0].initializer == "Role.Member"
210+
assert variables[0].is_exported is True
211+
212+
172213
def test_class_decorators(ts_analysis):
173214
decos = ts_analysis.get_class_decorators("src/controllers.UserController")
174215
assert [d.name for d in decos] == ["Controller"]
@@ -209,3 +250,79 @@ def test_source_code_mode_rejected(typescript_application):
209250
def test_python_only_kwargs_rejected(typescript_application):
210251
with pytest.raises(CldkInitializationException):
211252
CLDK(language="typescript").analysis(project_path=typescript_application, cache_dir="/tmp/x")
253+
254+
255+
# -----[ facade -> backend wiring / subprocess invocation ]-----
256+
def test_subprocess_args_stdout_pipe(typescript_application, typescript_analysis_json, monkeypatch):
257+
"""The facade must forward target_files + analysis_level to the backend, which builds the
258+
right subprocess command. With no analysis_json_path, output is read from the stdout pipe
259+
(no ``-o``)."""
260+
monkeypatch.setenv("CODEANALYZER_TS_BIN", "codeanalyzer-typescript")
261+
with patch("cldk.analysis.typescript.codeanalyzer.codeanalyzer.subprocess.run") as run_mock:
262+
run_mock.return_value = MagicMock(stdout=typescript_analysis_json, returncode=0)
263+
CLDK(language="typescript").analysis(
264+
project_path=typescript_application,
265+
analysis_backend_path=None,
266+
eager=True,
267+
analysis_level=AnalysisLevel.call_graph,
268+
target_files=["src/models.ts", "src/services.ts"],
269+
)
270+
271+
args = run_mock.call_args.args[0]
272+
assert args[0] == "codeanalyzer-typescript" # resolved from $CODEANALYZER_TS_BIN
273+
assert args[args.index("-i") + 1] == str(typescript_application)
274+
assert args[args.index("-a") + 1] == "2" # call_graph -> level 2
275+
# each target file forwarded with its own -t
276+
assert args.count("-t") == 2
277+
assert "src/models.ts" in args and "src/services.ts" in args
278+
# stdout-pipe mode: no output directory
279+
assert "-o" not in args
280+
281+
282+
def test_subprocess_args_output_dir(typescript_application, typescript_analysis_json, tmp_path, monkeypatch):
283+
"""With analysis_json_path set, the backend passes ``-o <dir>`` and reads analysis.json back
284+
from disk. Exercises the output-dir branch and level-1 (symbol_table) mapping."""
285+
monkeypatch.setenv("CODEANALYZER_TS_BIN", "codeanalyzer-typescript")
286+
out_dir = tmp_path / "out"
287+
out_dir.mkdir()
288+
289+
def fake_run(cmd, *a, **kw):
290+
# the analyzer writes analysis.json into the -o directory
291+
(out_dir / "analysis.json").write_text(typescript_analysis_json, encoding="utf-8")
292+
return MagicMock(stdout="", returncode=0)
293+
294+
with patch("cldk.analysis.typescript.codeanalyzer.codeanalyzer.subprocess.run", side_effect=fake_run) as run_mock:
295+
analysis = CLDK(language="typescript").analysis(
296+
project_path=typescript_application,
297+
analysis_backend_path=None,
298+
eager=True,
299+
analysis_level=AnalysisLevel.symbol_table,
300+
analysis_json_path=out_dir,
301+
)
302+
303+
args = run_mock.call_args.args[0]
304+
assert args[args.index("-a") + 1] == "1" # symbol_table -> level 1
305+
assert args[args.index("-o") + 1] == str(out_dir)
306+
# the disk read-back path produced a usable application
307+
assert len(analysis.get_symbol_table()) == 6
308+
309+
310+
def test_cached_analysis_json_skips_subprocess(typescript_application, typescript_analysis_json, tmp_path, monkeypatch):
311+
"""When a cached analysis.json already exists and eager is False, the backend must reuse it
312+
and not invoke the analyzer subprocess."""
313+
monkeypatch.setenv("CODEANALYZER_TS_BIN", "codeanalyzer-typescript")
314+
out_dir = tmp_path / "out"
315+
out_dir.mkdir()
316+
(out_dir / "analysis.json").write_text(typescript_analysis_json, encoding="utf-8")
317+
318+
with patch("cldk.analysis.typescript.codeanalyzer.codeanalyzer.subprocess.run") as run_mock:
319+
analysis = CLDK(language="typescript").analysis(
320+
project_path=typescript_application,
321+
analysis_backend_path=None,
322+
eager=False,
323+
analysis_level=AnalysisLevel.call_graph,
324+
analysis_json_path=out_dir,
325+
)
326+
327+
run_mock.assert_not_called()
328+
assert len(analysis.get_symbol_table()) == 6

0 commit comments

Comments
 (0)