Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cldk/analysis/typescript/backend.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]:
Expand Down
21 changes: 20 additions & 1 deletion cldk/analysis/typescript/codeanalyzer/codeanalyzer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
28 changes: 28 additions & 0 deletions cldk/analysis/typescript/neo4j/neo4j_backend.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]:
Expand Down
17 changes: 17 additions & 0 deletions tests/analysis/typescript/test_typescript_analysis.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,6 +151,23 @@ 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):
# "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 params == []


def test_call_sites(ts_analysis):
# rich syntactic call sites inside a callable
sites = ts_analysis.get_call_sites("src/controllers.UserController.show")
Expand Down
Loading