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
6 changes: 6 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,12 @@ scratch*
*.json
!devcontainer.json

# Blessed TS unit-test fixture: hand-built from a sample app whose source (incl. src/external.ts)
# was never committed, so it cannot be regenerated by running codeanalyzer-typescript again. The
# bulk-accessor tests assert exact-set constants (signature counts, ownerless sets) against this
# exact file -- losing it breaks the suite for every fresh clone (#298).
!tests/resources/typescript/analysis_json/slim/analysis.json


# Python compiled files and env
__pycache__/
Expand Down
33 changes: 33 additions & 0 deletions cldk/analysis/typescript/backend.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,7 @@
from cldk.models.typescript import (
TSApplication,
TSCallable,
TSCallableOverview,
TSCallsite,
TSClass,
TSClassAttribute,
Expand DownExpand Up@@ -244,3 +245,35 @@ def get_methods_with_decorators(self, decorators: List[str]) -> Dict[str, List[s
@abstractmethod
def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[str]]:
"""Map each requested decorator name to the signatures of classes carrying it."""

# -----[ bulk / projected accessors ]-----
# Set-at-a-time, field-projected reads — one round-trip on the Neo4j backend, one symbol-table
# walk in-process — for callers that enumerate the whole application and would otherwise pay the
# per-entity reconstruction of get_all_methods_in_application.
@abstractmethod
def get_callables_overview(self) -> List[TSCallableOverview]:
"""A lightweight projection of every callable in the application (methods, module-level,
namespace-level, and nested/inner functions), without the full :class:`TSCallable`
reconstruction.

Known limitation: a ``get x()``/``set x()`` accessor pair shares one ``signature``, so
this (and the other bulk accessors) can diverge between backends on a paired accessor —
see `#300 <https://github.com/codellm-devkit/python-sdk/issues/300>`_."""

@abstractmethod
def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
"""Source bodies for the given callable signatures, keyed by signature. Signatures with no
matching callable are omitted, as are callables whose ``code`` is ``None`` (e.g. implicit
constructors the analyzer synthesizes with no source text) — every returned value is a
real ``str``."""

@abstractmethod
def get_decorated_callables(self, markers: List[str]) -> List[TSCallableOverview]:
"""Overviews of callables decorated with any of ``markers`` (matched against the decorator
names)."""

@abstractmethod
def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[TSCallsite]]:
"""Call sites of the given callable signatures, keyed by owning signature. Each existing
signature gets an entry (an empty list if it has no call sites); signatures with no matching
callable are omitted."""
57 changes: 56 additions & 1 deletion cldk/analysis/typescript/codeanalyzer/codeanalyzer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@
from collections import deque
from pathlib import Path
from subprocess import CompletedProcess
from typing import Dict, List, Set, Tuple, Union
from typing import Dict, Iterator, List, Set, Tuple, Union

import networkx as nx

Expand All@@ -42,6 +42,7 @@
from cldk.models.typescript import (
TSApplication,
TSCallable,
TSCallableOverview,
TSCallsite,
TSClass,
TSClassAttribute,
Expand DownExpand Up@@ -554,3 +555,57 @@ def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[s
if dec.name in wanted:
result[dec.name].append(sig)
return result

# -----[ bulk / projected accessors ]-----
def _iter_callables(self) -> Iterator[Tuple[TSCallable, str | None, str | None]]:
"""Yield ``(callable, owner_signature, owner_kind)`` for every callable in the
application, including inner/nested callables. The owner map is built only from
``_methods_by_class`` keyed against ``_classes``/``_interfaces``: namespace-owned
functions and module-level/nested callables are never in that map, so they correctly come
out owner-less (None, None), per the closed "class"|"interface" owner_kind set."""
owner_of: Dict[str, Tuple[str, str]] = {}
for owner_sig, methods in self._methods_by_class.items():
if owner_sig in self._classes:
owner_kind = "class"
elif owner_sig in self._interfaces:
owner_kind = "interface"
else:
continue
for m in methods.values():
owner_of[m.signature] = (owner_sig, owner_kind)
for sig, c in self._callables.items():
owner_sig, owner_kind = owner_of.get(sig, (None, None))
yield c, owner_sig, owner_kind

def get_callables_overview(self) -> List[TSCallableOverview]:
"""Return a lightweight overview of every callable in the application (see
:meth:`TSAnalysisBackend.get_callables_overview`)."""
return [TSCallableOverview.from_callable(c, owner_sig, owner_kind) for c, owner_sig, owner_kind in self._iter_callables()]

def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
"""Return ``{signature: code}`` for the requested signatures that exist and have a body
(omits callables whose ``code`` is ``None``, e.g. implicit constructors)."""
result: Dict[str, str] = {}
for sig in signatures:
c = self._callables.get(sig)
if c is not None and c.code is not None:
result[sig] = c.code
return result

def get_decorated_callables(self, markers: List[str]) -> List[TSCallableOverview]:
"""Return overviews of callables decorated with any of ``markers``."""
marker_set = set(markers)
return [
TSCallableOverview.from_callable(c, owner_sig, owner_kind)
for c, owner_sig, owner_kind in self._iter_callables()
if marker_set.intersection(d.name for d in c.decorators)
]

def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[TSCallsite]]:
"""Return ``{signature: call_sites}`` for the requested signatures that exist."""
result: Dict[str, List[TSCallsite]] = {}
for sig in signatures:
c = self._callables.get(sig)
if c is not None:
result[sig] = list(c.call_sites)
return result
64 changes: 64 additions & 0 deletions cldk/analysis/typescript/neo4j/neo4j_backend.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,6 +63,7 @@
from cldk.models.typescript import (
TSApplication,
TSCallable,
TSCallableOverview,
TSCallEdge,
TSCallsite,
TSClass,
Expand DownExpand Up@@ -710,3 +711,66 @@ def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[s
for r in rows:
result[r["dn"]].append(r["sig"])
return result

# -----[ bulk / projected accessors ]-----
# Field-projected RETURNs that sidestep the per-entity reconstruction fan-out: each is a single
# Cypher statement (one round trip), not the child-fetch walk _callable_full pays.
#
# Owner leg: (o:Symbol)-[:HAS_METHOD]->(c) only ever connects a Class/Interface owner to one of
# its methods, so it naturally has no match for module-level, namespace-owned, or nested
# callables -- they fall out owner-less (None/None) with no separate namespace leg needed.
_OVERVIEW_RETURN = (
"OPTIONAL MATCH (o:Symbol)-[:HAS_METHOD]->(c) "
"OPTIONAL MATCH (c)-[:DECORATED_BY]->(d:Decorator) "
"RETURN c.signature AS signature, c.name AS name, c.kind AS kind, c.path AS path, "
"c.start_line AS start_line, c.end_line AS end_line, "
"c.is_exported AS is_exported, c.is_async AS is_async, c.is_static AS is_static, "
"c.accessibility AS accessibility, "
"o.signature AS owner_signature, labels(o) AS owner_labels, "
"collect(DISTINCT d.name) AS decorators"
)

def get_callables_overview(self) -> List[TSCallableOverview]:
rows = self._run(
"MATCH (c:Callable) WHERE c._module IN $mods " + self._OVERVIEW_RETURN,
mods=self._modules,
)
return [R.overview(r) for r in rows]

def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
rows = self._run(
"MATCH (c:Callable) WHERE c._module IN $mods AND c.signature IN $sigs AND c.code IS NOT NULL "
"RETURN c.signature AS signature, c.code AS code",
mods=self._modules,
sigs=list(signatures),
)
return {r["signature"]: r["code"] for r in rows}

def get_decorated_callables(self, markers: List[str]) -> List[TSCallableOverview]:
rows = self._run(
"MATCH (c:Callable)-[:DECORATED_BY]->(marker:Decorator) "
"WHERE c._module IN $mods AND marker.name IN $markers "
"WITH DISTINCT c " + self._OVERVIEW_RETURN,
mods=self._modules,
markers=list(markers),
)
return [R.overview(r) for r in rows]

def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[TSCallsite]]:
# OPTIONAL MATCH so a requested callable with no call sites still yields a row (p is null),
# giving it an empty-list entry -- parity with the in-process backend, which keys every
# existing signature. ORDER mirrors _callsites_of's call-site ordering.
rows = self._run(
"MATCH (c:Callable) WHERE c._module IN $mods AND c.signature IN $sigs "
"OPTIONAL MATCH (c)-[:HAS_CALLSITE]->(cs:CallSite) "
"RETURN c.signature AS owner, properties(cs) AS p "
"ORDER BY cs.start_line, cs.start_column",
mods=self._modules,
sigs=list(signatures),
)
out: Dict[str, List[TSCallsite]] = {}
for r in rows:
sites = out.setdefault(r["owner"], [])
if r["p"] is not None:
sites.append(R.callsite(r["p"]))
return out
40 changes: 40 additions & 0 deletions cldk/analysis/typescript/neo4j/reconstruct.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@

from cldk.models.typescript import (
TSCallable,
TSCallableOverview,
TSCallableParameter,
TSCallsite,
TSClass,
Expand DownExpand Up@@ -191,6 +192,45 @@ def synthesized(props: Props) -> TSSynthesizedCallable:
)


def overview(row: Props) -> TSCallableOverview:
"""Build a :class:`TSCallableOverview` from a projected callable row (a flat ``RETURN``
projection, not a node's ``properties()``): ``signature``/``name``/``kind``/``path``/
``start_line``/``end_line``/``is_exported``/``is_async``/``is_static``/``accessibility`` plus
``owner_signature``/``owner_labels`` (the ``HAS_METHOD`` owner leg — absent, i.e. both null,
for module-level/namespace-owned/nested callables) and ``decorators`` (collected decorator
names).

``owner_kind`` is derived from ``owner_labels`` rather than stored directly: ``"class"`` if the
owner node carries the ``Class`` label, ``"interface"`` if it carries ``Interface``, else
``None`` — matching the closed two-value ``owner_kind`` set the in-memory backend produces.
"""
owner_signature = row.get("owner_signature")
owner_labels = row.get("owner_labels") or []
if owner_signature is None:
owner_kind = None
elif "Class" in owner_labels:
owner_kind = "class"
elif "Interface" in owner_labels:
owner_kind = "interface"
else:
owner_kind = None
return TSCallableOverview(
signature=row.get("signature", ""),
name=row.get("name", ""),
owner_signature=owner_signature,
owner_kind=owner_kind,
kind=row.get("kind", "function"),
path=row.get("path", ""),
start_line=row.get("start_line", -1),
end_line=row.get("end_line", -1),
decorators=[d for d in (row.get("decorators") or []) if d is not None],
is_exported=bool(row.get("is_exported", False)),
is_async=bool(row.get("is_async", False)),
is_static=bool(row.get("is_static", False)),
accessibility=row.get("accessibility"),
)


# ----------------------------------------------------------------------------------------------
# declaration nodes (children supplied by the backend)
# ----------------------------------------------------------------------------------------------
Expand Down
73 changes: 73 additions & 0 deletions cldk/analysis/typescript/typescript_analysis.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@
from cldk.models.typescript import (
TSApplication,
TSCallable,
TSCallableOverview,
TSCallsite,
TSClass,
TSClassAttribute,
Expand DownExpand Up@@ -297,3 +298,75 @@ def get_methods_with_decorators(self, decorators: List[str]) -> Dict[str, List[s
def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[str]]:
"""Map each requested decorator name to the signatures of classes carrying it."""
return self.backend.get_classes_with_decorators(decorators)

# -----[ bulk / projected accessors ]-----
def get_callables_overview(self) -> List[TSCallableOverview]:
"""Return a lightweight overview of every callable in the project, in one bulk read.

A field-projected alternative to :meth:`get_methods` for enumeration: each
:class:`~cldk.models.typescript.TSCallableOverview` carries the callable's signature,
owning class/interface (if any), native kind, location, and decorators — but not the full
reconstruction (call sites, inner callables, locals). On the Neo4j backend this is a single
Cypher query instead of the per-entity fan-out :meth:`get_methods` pays. Body-inspect the
few you need afterwards via :meth:`get_method` or :meth:`get_method_bodies`.

Returns:
A flat list of :class:`~cldk.models.typescript.TSCallableOverview`, one per callable
(class/interface methods, module- and namespace-level functions, and nested/inner
callables).

See Also:
:meth:`get_decorated_callables`: The same projection filtered by decorator.
:meth:`get_method_bodies`: Bulk source-body fetch for chosen signatures.

Note:
A ``get x()``/``set x()`` accessor pair shares one ``signature``, so this projection
(and the other bulk accessors) can diverge between the local and Neo4j backends on a
paired accessor — see `#300 <https://github.com/codellm-devkit/python-sdk/issues/300>`_.
"""
return self.backend.get_callables_overview()

def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
"""Return source bodies for the given callable signatures, in one bulk read.

Args:
signatures: Callable signatures to fetch bodies for (e.g. from
:meth:`get_callables_overview`).

Returns:
A dict mapping each signature to its source body. Signatures with no matching callable
are omitted, as are callables whose ``code`` is ``None`` (e.g. implicit constructors
the analyzer synthesizes with no source text) — every returned value is a real ``str``.
"""
return self.backend.get_method_bodies(signatures)

def get_decorated_callables(self, markers: List[str]) -> List[TSCallableOverview]:
"""Return overviews of callables decorated with any of the given markers, in one bulk read.

Args:
markers: Decorator names to match (e.g. ``["Get", "Controller"]``).

Returns:
A list of :class:`~cldk.models.typescript.TSCallableOverview` for every callable
carrying at least one of ``markers`` as a decorator.

See Also:
:meth:`get_callables_overview`: The unfiltered projection.
"""
return self.backend.get_decorated_callables(markers)

def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[TSCallsite]]:
"""Return the call sites of the given callables, keyed by signature, in one bulk read.

Avoids the per-callable reconstruction fan-out when you need call sites for a specific
frontier (e.g. dispatch-edge synthesis or external-reader detection).

Args:
signatures: Callable signatures to fetch call sites for.

Returns:
A dict mapping each existing signature to its list of
:class:`~cldk.models.typescript.TSCallsite` (empty if the callable has no call sites).
Signatures with no matching callable are omitted.
"""
return self.backend.get_callsites_for(signatures)
2 changes: 2 additions & 0 deletions cldk/models/typescript/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,11 +42,13 @@
TSTypeParameter,
TSVariableDeclaration,
)
from .projections import TSCallableOverview

__all__ = [
"TSApplication",
"TSCallEdge",
"TSCallable",
"TSCallableOverview",
"TSCallableParameter",
"TSCallsite",
"TSClass",
Expand Down
Loading