From 64a2f6324377ebf9361ad31add1793fd2ce056fb Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 10:40:16 -0400 Subject: [PATCH 1/2] fix(typescript): resolve module-level functions in get_method (#247) get_method only ever looked at _methods_by_class (local backend) / matched [:HAS_METHOD] (Neo4j backend), so module- and namespace-level functions -- the dominant callable kind in most TS code -- were unreachable through it, and get_method_parameters inherited the false-empty miss. Both backends now fall back to function resolution when the class lookup misses: an exact signature match first, then a short-name match scoped under the given class/module/namespace. The local backend resolves against _functions; the Neo4j backend runs a DECLARES-based fallback query mirroring get_all_functions. _resolve_signature's composed-guess fallback is untouched. --- .../typescript/codeanalyzer/codeanalyzer.py | 21 +- .../typescript/neo4j/neo4j_backend.py | 28 ++ .../typescript/test_typescript_analysis.py | 14 + .../test_typescript_get_method_functions.py | 306 ++++++++++++++++++ .../test_typescript_neo4j_backend.py | 14 + 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 tests/analysis/typescript/test_typescript_get_method_functions.py diff --git a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py index 353fe8e..4c442f4 100644 --- a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py @@ -472,7 +472,26 @@ def get_all_methods_in_class(self, qualified_class_name: str) -> Dict[str, TSCal return self._methods_by_class.get(qualified_class_name, {}) def get_method(self, qualified_class_name: str, qualified_method_name: str) -> TSCallable | None: - return self._methods_by_class.get(qualified_class_name, {}).get(qualified_method_name) + method = self._methods_by_class.get(qualified_class_name, {}).get(qualified_method_name) + if method is not None: + return method + # Class lookup missed (or the scope isn't a class at all): fall back to module/namespace + # -level functions, which live in `_functions` rather than `_methods_by_class`. + return self._resolve_function(qualified_class_name, qualified_method_name) + + def _resolve_function(self, scope: str, name: str) -> TSCallable | None: + """Resolve a module/namespace-level function: an exact signature match first (``name`` is + already a full signature, ``scope`` ignored), then a short-name match scoped under + ``scope`` (handles functions nested in a namespace the caller doesn't know the full path + of, e.g. ``StringUtil.repeat`` when the caller only knows the module ``src/util``).""" + exact = self._functions.get(name) + if exact is not None: + return exact + prefix = f"{scope}." + for sig, fn in self._functions.items(): + if fn.name == name and sig.startswith(prefix): + return fn + return None def get_method_parameters(self, qualified_class_name: str, qualified_method_name: str) -> List[str]: method = self.get_method(qualified_class_name, qualified_method_name) diff --git a/cldk/analysis/typescript/neo4j/neo4j_backend.py b/cldk/analysis/typescript/neo4j/neo4j_backend.py index 71f7fdd..3025e1c 100644 --- a/cldk/analysis/typescript/neo4j/neo4j_backend.py +++ b/cldk/analysis/typescript/neo4j/neo4j_backend.py @@ -622,6 +622,34 @@ def get_method(self, qualified_class_name: str, qualified_method_name: str) -> T sig=qualified_class_name, name=qualified_method_name, ) + if rows: + return self._callable_full(rows[0]["p"]) + # Class lookup missed (or the scope isn't a class at all): fall back to module/namespace + # -level functions via DECLARES, mirroring get_all_functions. + return self._resolve_function(qualified_class_name, qualified_method_name) + + def _resolve_function(self, scope: str, name: str) -> TSCallable | None: + """Resolve a module/namespace-level function: an exact signature match first (``name`` is + already a full signature, ``scope`` ignored), then a short-name match scoped under + ``scope`` (handles functions nested in a namespace the caller doesn't know the full path + of).""" + rows = self._run( + "MATCH (parent)-[:DECLARES]->(c:Callable {signature: $sig}) " + "WHERE (parent:Module OR parent:Namespace) AND c._module IN $mods " + "RETURN properties(c) AS p LIMIT 1", + sig=name, + mods=self._modules, + ) + if rows: + return self._callable_full(rows[0]["p"]) + rows = self._run( + "MATCH (parent)-[:DECLARES]->(c:Callable {name: $name}) " + "WHERE (parent:Module OR parent:Namespace) AND c._module IN $mods AND c.signature STARTS WITH $prefix " + "RETURN properties(c) AS p LIMIT 1", + name=name, + mods=self._modules, + prefix=f"{scope}.", + ) return self._callable_full(rows[0]["p"]) if rows else None def get_method_parameters(self, qualified_class_name: str, qualified_method_name: str) -> List[str]: diff --git a/tests/analysis/typescript/test_typescript_analysis.py b/tests/analysis/typescript/test_typescript_analysis.py index 15fe02c..01a7e35 100644 --- a/tests/analysis/typescript/test_typescript_analysis.py +++ b/tests/analysis/typescript/test_typescript_analysis.py @@ -151,6 +151,20 @@ def test_callers_and_callees(ts_analysis): assert "provenance" in main_edge and "tags" in main_edge +def test_get_method_resolves_module_level_function(ts_analysis): + # regression for #247: get_method used to be class-scope only, so "src/index.main" (a + # module-level function that participates in a call edge, see test_callers_and_callees) was + # unreachable through it. + method = ts_analysis.get_method("src/index", "main") + assert method is not None + assert method.signature == "src/index.main" + + +def test_get_method_parameters_module_level_function(ts_analysis): + params = ts_analysis.get_method_parameters("src/index", "main") + assert isinstance(params, list) + + def test_call_sites(ts_analysis): # rich syntactic call sites inside a callable sites = ts_analysis.get_call_sites("src/controllers.UserController.show") diff --git a/tests/analysis/typescript/test_typescript_get_method_functions.py b/tests/analysis/typescript/test_typescript_get_method_functions.py new file mode 100644 index 0000000..962f62f --- /dev/null +++ b/tests/analysis/typescript/test_typescript_get_method_functions.py @@ -0,0 +1,306 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Regression tests for #247: ``get_method``/``get_method_parameters`` must resolve module- and +namespace-level functions, not just class methods, on both the local and Neo4j backends. + +The shared repo fixture (``typescript_analysis_json`` / a live Neo4j) is not always available in +every sandbox (see ``tests/analysis/typescript/conftest.py`` and the Neo4j skip-gate in +``test_typescript_neo4j_backend.py``), so this module builds its own minimal, self-contained +``TSApplication`` fixture directly from the pydantic models. The same underlying data seeds both +backends (the local one via a mocked subprocess, the Neo4j one via a stubbed ``_run``), so the +parity test below is comparing apples to apples. +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cldk import CLDK +from cldk.analysis import AnalysisLevel +from cldk.analysis.commons.backend_config import CodeAnalyzerConfig +from cldk.analysis.typescript.neo4j import TSNeo4jBackend +from cldk.models.typescript import ( + TSApplication, + TSCallable, + TSCallableParameter, + TSCallEdge, + TSClass, + TSModule, + TSNamespace, +) + +# -----[ shared fixture data ]----- +# +# src/mod.ts: +# class Foo { bar() {} } -> src/mod.Foo.bar +# function baz(x) { Foo.prototype.bar(); } -> src/mod.baz (module-level function) +# namespace NS { function qux() { baz(); } } -> src/mod.NS.qux (namespace-nested function) +# +# call_graph: baz -> Foo.bar, NS.qux -> baz (so both functions participate in a call edge) + + +def _bar() -> TSCallable: + return TSCallable(name="bar", path="src/mod.ts", signature="src/mod.Foo.bar", kind="method") + + +def _baz() -> TSCallable: + return TSCallable( + name="baz", + path="src/mod.ts", + signature="src/mod.baz", + kind="function", + parameters=[TSCallableParameter(name="x")], + ) + + +def _qux() -> TSCallable: + return TSCallable(name="qux", path="src/mod.ts", signature="src/mod.NS.qux", kind="function") + + +def _build_application() -> TSApplication: + foo = TSClass(name="Foo", signature="src/mod.Foo", methods={"bar": _bar()}) + ns = TSNamespace(name="NS", signature="src/mod.NS", functions={"src/mod.NS.qux": _qux()}) + module = TSModule( + file_path="src/mod.ts", + module_name="mod", + classes={"src/mod.Foo": foo}, + functions={"src/mod.baz": _baz()}, + namespaces={"src/mod.NS": ns}, + ) + return TSApplication( + symbol_table={"src/mod.ts": module}, + call_graph=[ + TSCallEdge(source="src/mod.baz", target="src/mod.Foo.bar"), + TSCallEdge(source="src/mod.NS.qux", target="src/mod.baz"), + ], + ) + + +# -----[ local (in-memory) backend ]----- + + +def _fake_run_writing_output(payload: str): + def _run(cmd, *args, **kwargs): + if "-o" in cmd: + out = Path(cmd[cmd.index("-o") + 1]) + out.mkdir(parents=True, exist_ok=True) + (out / "analysis.json").write_text(payload, encoding="utf-8") + return MagicMock(stdout=payload, returncode=0) + + return _run + + +@pytest.fixture +def ts_analysis(typescript_application, tmp_path, monkeypatch): + """A local-backend facade over the minimal module-function fixture above.""" + payload = _build_application().model_dump_json() + monkeypatch.setenv("CODEANALYZER_TS_BIN", "codeanalyzer-typescript") + with patch( + "cldk.analysis.typescript.codeanalyzer.codeanalyzer.subprocess.run", + side_effect=_fake_run_writing_output(payload), + ): + return CLDK.typescript( + project_path=typescript_application, + eager=True, + analysis_level=AnalysisLevel.call_graph, + backend=CodeAnalyzerConfig(cache_dir=str(tmp_path)), + ) + + +def test_local_get_method_still_resolves_class_methods(ts_analysis): + method = ts_analysis.get_method("src/mod.Foo", "bar") + assert method is not None + assert method.signature == "src/mod.Foo.bar" + + +def test_local_get_method_resolves_module_level_function_by_short_name(ts_analysis): + method = ts_analysis.get_method("src/mod", "baz") + assert method is not None + assert method.signature == "src/mod.baz" + + +def test_local_get_method_resolves_by_exact_signature(ts_analysis): + # scope is irrelevant/ignored when the method arg is already a full signature + method = ts_analysis.get_method("whatever", "src/mod.baz") + assert method is not None + assert method.signature == "src/mod.baz" + + +def test_local_get_method_resolves_namespace_nested_function_by_short_name(ts_analysis): + # "qux" isn't reachable via the naive "src/mod.qux" composed guess -- only via a short-name + # search scoped under "src/mod". + method = ts_analysis.get_method("src/mod", "qux") + assert method is not None + assert method.signature == "src/mod.NS.qux" + + +def test_local_get_method_parameters_module_level_function(ts_analysis): + assert ts_analysis.get_method_parameters("src/mod", "baz") == ["x"] + + +def test_local_get_method_genuine_miss_returns_none(ts_analysis): + assert ts_analysis.get_method("src/mod", "does_not_exist") is None + assert ts_analysis.get_method_parameters("src/mod", "does_not_exist") == [] + + +def test_local_module_function_participates_in_call_edge(ts_analysis): + # sanity check on the fixture itself: baz is a real call-graph participant + graph = ts_analysis.get_call_graph() + assert graph.has_edge("src/mod.baz", "src/mod.Foo.bar") + + +# -----[ Neo4j backend ]----- +# +# No live Neo4j is assumed to be reachable in every environment (see the skip-gate in +# test_typescript_neo4j_backend.py). ``_run`` is the single seam every query method goes through, +# so it's stubbed here with canned rows equivalent to the same fixture data above -- this is a +# unit-level substitute for the (separately maintained) live-Neo4j integration coverage. + + +def _neo4j_backend_with_stubbed_run(rows_by_call: dict) -> TSNeo4jBackend: + """A TSNeo4jBackend with __init__ (and its real driver connection) bypassed, ``_run`` stubbed + to return canned rows keyed by (query, frozenset(params.items())).""" + backend = object.__new__(TSNeo4jBackend) + backend.application_name = "test-app" + backend._database = None + backend._modules = ["src/mod.ts"] + + def _normalize(value): + return tuple(value) if isinstance(value, list) else value + + def _run(query: str, **params): + normalized = {k: _normalize(v) for k, v in params.items()} + for (q, p), result in rows_by_call.items(): + if q == query and dict(p) == normalized: + return result + return [] + + backend._run = _run + return backend + + +def _callable_props(c: TSCallable) -> dict: + """The flattened Neo4j node property shape ``reconstruct.callable_`` expects (see + ``codeanalyzer-ts/src/build/neo4j/project.ts``): JSON-encoded ``*_json`` scalars rather than + nested lists, matching what a real projection would have written.""" + import json as _json + + return { + "name": c.name, + "path": c.path, + "signature": c.signature, + "parameters_json": _json.dumps([p.model_dump() for p in c.parameters]), + "return_type": c.return_type, + "start_line": c.start_line, + "end_line": c.end_line, + "kind": c.kind, + } + + +@pytest.fixture +def stub_neo4j_backend(): + bar_props = _callable_props(_bar()) + baz_props = _callable_props(_baz()) + qux_props = _callable_props(_qux()) + + has_method_query = "MATCH (o:Symbol {signature: $sig})-[:HAS_METHOD]->(m:Callable {name: $name}) RETURN properties(m) AS p LIMIT 1" + exact_sig_query = ( + "MATCH (parent)-[:DECLARES]->(c:Callable {signature: $sig}) " + "WHERE (parent:Module OR parent:Namespace) AND c._module IN $mods " + "RETURN properties(c) AS p LIMIT 1" + ) + short_name_query = ( + "MATCH (parent)-[:DECLARES]->(c:Callable {name: $name}) " + "WHERE (parent:Module OR parent:Namespace) AND c._module IN $mods AND c.signature STARTS WITH $prefix " + "RETURN properties(c) AS p LIMIT 1" + ) + + rows_by_call = { + # class method lookup: hits + (has_method_query, (("sig", "src/mod.Foo"), ("name", "bar"))): [{"p": bar_props}], + # class method lookup: misses for module/namespace scopes + (has_method_query, (("sig", "src/mod"), ("name", "baz"))): [], + (has_method_query, (("sig", "whatever"), ("name", "src/mod.baz"))): [], + (has_method_query, (("sig", "src/mod"), ("name", "qux"))): [], + (has_method_query, (("sig", "src/mod"), ("name", "does_not_exist"))): [], + # exact-signature DECLARES fallback + (exact_sig_query, (("mods", ("src/mod.ts",)), ("sig", "src/mod.baz"))): [{"p": baz_props}], + (exact_sig_query, (("mods", ("src/mod.ts",)), ("sig", "baz"))): [], + (exact_sig_query, (("mods", ("src/mod.ts",)), ("sig", "does_not_exist"))): [], + (exact_sig_query, (("mods", ("src/mod.ts",)), ("sig", "qux"))): [], + # short-name DECLARES fallback, scoped under the given scope + (short_name_query, (("mods", ("src/mod.ts",)), ("name", "baz"), ("prefix", "src/mod."))): [{"p": baz_props}], + (short_name_query, (("mods", ("src/mod.ts",)), ("name", "qux"), ("prefix", "src/mod."))): [{"p": qux_props}], + (short_name_query, (("mods", ("src/mod.ts",)), ("name", "does_not_exist"), ("prefix", "src/mod."))): [], + } + return _neo4j_backend_with_stubbed_run(rows_by_call) + + +def test_neo4j_get_method_still_resolves_class_methods(stub_neo4j_backend): + method = stub_neo4j_backend.get_method("src/mod.Foo", "bar") + assert method is not None + assert method.signature == "src/mod.Foo.bar" + + +def test_neo4j_get_method_resolves_module_level_function_by_short_name(stub_neo4j_backend): + method = stub_neo4j_backend.get_method("src/mod", "baz") + assert method is not None + assert method.signature == "src/mod.baz" + + +def test_neo4j_get_method_resolves_by_exact_signature(stub_neo4j_backend): + method = stub_neo4j_backend.get_method("whatever", "src/mod.baz") + assert method is not None + assert method.signature == "src/mod.baz" + + +def test_neo4j_get_method_resolves_namespace_nested_function_by_short_name(stub_neo4j_backend): + method = stub_neo4j_backend.get_method("src/mod", "qux") + assert method is not None + assert method.signature == "src/mod.NS.qux" + + +def test_neo4j_get_method_parameters_module_level_function(stub_neo4j_backend): + assert stub_neo4j_backend.get_method_parameters("src/mod", "baz") == ["x"] + + +def test_neo4j_get_method_genuine_miss_returns_none(stub_neo4j_backend): + assert stub_neo4j_backend.get_method("src/mod", "does_not_exist") is None + assert stub_neo4j_backend.get_method_parameters("src/mod", "does_not_exist") == [] + + +# -----[ backend parity ]----- + + +def test_backend_parity_module_level_function(ts_analysis, stub_neo4j_backend): + local = ts_analysis.get_method("src/mod", "baz") + remote = stub_neo4j_backend.get_method("src/mod", "baz") + assert local.signature == remote.signature + assert local.name == remote.name + assert ts_analysis.get_method_parameters("src/mod", "baz") == stub_neo4j_backend.get_method_parameters("src/mod", "baz") + + +def test_backend_parity_namespace_nested_function(ts_analysis, stub_neo4j_backend): + local = ts_analysis.get_method("src/mod", "qux") + remote = stub_neo4j_backend.get_method("src/mod", "qux") + assert local.signature == remote.signature + + +def test_backend_parity_genuine_miss(ts_analysis, stub_neo4j_backend): + assert ts_analysis.get_method("src/mod", "does_not_exist") is None + assert stub_neo4j_backend.get_method("src/mod", "does_not_exist") is None diff --git a/tests/analysis/typescript/test_typescript_neo4j_backend.py b/tests/analysis/typescript/test_typescript_neo4j_backend.py index 6fb14fe..d3b7053 100644 --- a/tests/analysis/typescript/test_typescript_neo4j_backend.py +++ b/tests/analysis/typescript/test_typescript_neo4j_backend.py @@ -175,6 +175,20 @@ def test_fields_and_parameters(ts_neo4j): assert isinstance(params, list) +def test_get_method_resolves_module_level_function(ts_neo4j): + # regression for #247: get_method used to be class-scope only, so "src/index.main" (a + # module-level function that participates in a call edge, see test_callers_and_callees) was + # unreachable through it. + method = ts_neo4j.get_method("src/index", "main") + assert method is not None + assert method.signature == "src/index.main" + + +def test_get_method_parameters_module_level_function(ts_neo4j): + params = ts_neo4j.get_method_parameters("src/index", "main") + assert isinstance(params, list) + + def test_structured_decorators(ts_neo4j): decorated = ts_neo4j.get_methods_with_decorators(["Controller", "Get"]) assert any(sig.endswith("UserController.show") for sig in decorated["Get"]) From ec284c19353844e6e1d14d6878b555958054efd2 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 11:42:59 -0400 Subject: [PATCH 2/2] docs+test(typescript): document module-level get_method; strengthen parameter assertions (#247) --- cldk/analysis/typescript/backend.py | 5 ++++- tests/analysis/typescript/test_typescript_analysis.py | 5 ++++- tests/analysis/typescript/test_typescript_neo4j_backend.py | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index 5d28024..6b75519 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -190,7 +190,10 @@ def get_all_methods_in_class(self, qualified_class_name: str) -> Dict[str, TSCal @abstractmethod def get_method(self, qualified_class_name: str, qualified_method_name: str) -> TSCallable | None: - """A single method of a class/interface.""" + """A single method of a class/interface, or a module/namespace-level function. + ``qualified_class_name`` accepts either a class/interface signature (resolving to that + type's methods) or a module/namespace scope, in which case module-level functions are + resolved as a fallback; returns ``None`` if nothing resolves.""" @abstractmethod def get_method_parameters(self, qualified_class_name: str, qualified_method_name: str) -> List[str]: diff --git a/tests/analysis/typescript/test_typescript_analysis.py b/tests/analysis/typescript/test_typescript_analysis.py index 01a7e35..76514cc 100644 --- a/tests/analysis/typescript/test_typescript_analysis.py +++ b/tests/analysis/typescript/test_typescript_analysis.py @@ -161,8 +161,11 @@ def test_get_method_resolves_module_level_function(ts_analysis): def test_get_method_parameters_module_level_function(ts_analysis): + # "main" is declared as `function main(): void` (see index.ts), so it takes no parameters — + # this exercises the module-level fallback path in get_method_parameters/get_method, not just + # that some list comes back. params = ts_analysis.get_method_parameters("src/index", "main") - assert isinstance(params, list) + assert params == [] def test_call_sites(ts_analysis): diff --git a/tests/analysis/typescript/test_typescript_neo4j_backend.py b/tests/analysis/typescript/test_typescript_neo4j_backend.py index d3b7053..8013c55 100644 --- a/tests/analysis/typescript/test_typescript_neo4j_backend.py +++ b/tests/analysis/typescript/test_typescript_neo4j_backend.py @@ -185,8 +185,11 @@ def test_get_method_resolves_module_level_function(ts_neo4j): def test_get_method_parameters_module_level_function(ts_neo4j): + # "main" is declared as `function main(): void` (see index.ts), so it takes no parameters — + # this exercises the module-level fallback path in get_method_parameters/get_method, not just + # that some list comes back. params = ts_neo4j.get_method_parameters("src/index", "main") - assert isinstance(params, list) + assert params == [] def test_structured_decorators(ts_neo4j):