Skip to content

Commit 26bdce2

Browse files
authored
feat(typescript): four bulk/projected accessors on the TS surface (#298) (#302)
* feat(typescript): TSCallableOverview projection model (#298) * fix(typescript): close owner_kind to class|interface; require facet fields (#298) * feat(typescript): bulk accessors on the ABC, in-memory backend, and facade (#298) * feat(typescript): bulk accessors on the Neo4j backend (#298) * fix(typescript): get_method_bodies omits code-less callables (#298) * test(typescript): pin overview Cypher shape in Neo4j bulk stub tests (#298) * test(typescript): dual-backend parity for the bulk accessors (#298) * test(typescript): track the blessed slim analysis fixture (#298) tests/resources/typescript/analysis_json/slim/analysis.json was caught by the blanket *.json gitignore rule and never committed, even though the TS bulk accessor tests assert exact-set constants against it and it was hand-built from a src/external.ts that was itself never committed -- so a fresh clone could neither run the suite nor regenerate the fixture. Add a narrow .gitignore exception and track the file. * docs(typescript): note accessor-pair limitation; file parity issue (#298) get x()/set x() pairs share one TSCallable.signature, so the in-memory backend's last-writer-wins _callables map collapses the pair to a single row while the Neo4j backend (one node per accessor) surfaces two -- and duplicate decorator names diverge the same way via collect(DISTINCT ...). Invisible on the current sample-app fixture (unpaired getter only), but the live parity suite would catch it on any app with a real paired accessor. Filed as #300; note it on the ABC and facade get_callables_overview docstrings, and record that decorator order isn't part of the cross-backend contract. * chore(typescript): exact ownerless set, direct lookups, delegation test, decorator-order note (#298) - test_overview_owner_pair_is_none_for_ownerless_callables: fix the "owned_less" typo and assert the ownerless signature set exactly, not just membership. - get_method_bodies / get_callsites_for: replace the O(all callables) walk via _iter_callables() with direct _callables.get(sig) lookups keyed on the requested signatures; omission/empty-entry semantics unchanged (covering tests stay green). - test_facade_delegates_to_backend: replace the same-fixture double-call (which only proved the fixture is deterministic) with a MagicMock-backed facade asserting each bulk accessor calls the identical backend method with the identical arguments and returns its exact object.
1 parent b7b56f6 commit 26bdce2

14 files changed

Lines changed: 5073 additions & 1 deletion

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ scratch*
5353
*.json
5454
!devcontainer.json
5555

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

5763
# Python compiled files and env
5864
__pycache__/

cldk/analysis/typescript/backend.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from cldk.models.typescript import (
4545
TSApplication,
4646
TSCallable,
47+
TSCallableOverview,
4748
TSCallsite,
4849
TSClass,
4950
TSClassAttribute,
@@ -244,3 +245,35 @@ def get_methods_with_decorators(self, decorators: List[str]) -> Dict[str, List[s
244245
@abstractmethod
245246
def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[str]]:
246247
"""Map each requested decorator name to the signatures of classes carrying it."""
248+
249+
# -----[ bulk / projected accessors ]-----
250+
# Set-at-a-time, field-projected reads — one round-trip on the Neo4j backend, one symbol-table
251+
# walk in-process — for callers that enumerate the whole application and would otherwise pay the
252+
# per-entity reconstruction of get_all_methods_in_application.
253+
@abstractmethod
254+
def get_callables_overview(self) -> List[TSCallableOverview]:
255+
"""A lightweight projection of every callable in the application (methods, module-level,
256+
namespace-level, and nested/inner functions), without the full :class:`TSCallable`
257+
reconstruction.
258+
259+
Known limitation: a ``get x()``/``set x()`` accessor pair shares one ``signature``, so
260+
this (and the other bulk accessors) can diverge between backends on a paired accessor —
261+
see `#300 <https://github.com/codellm-devkit/python-sdk/issues/300>`_."""
262+
263+
@abstractmethod
264+
def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
265+
"""Source bodies for the given callable signatures, keyed by signature. Signatures with no
266+
matching callable are omitted, as are callables whose ``code`` is ``None`` (e.g. implicit
267+
constructors the analyzer synthesizes with no source text) — every returned value is a
268+
real ``str``."""
269+
270+
@abstractmethod
271+
def get_decorated_callables(self, markers: List[str]) -> List[TSCallableOverview]:
272+
"""Overviews of callables decorated with any of ``markers`` (matched against the decorator
273+
names)."""
274+
275+
@abstractmethod
276+
def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[TSCallsite]]:
277+
"""Call sites of the given callable signatures, keyed by owning signature. Each existing
278+
signature gets an entry (an empty list if it has no call sites); signatures with no matching
279+
callable are omitted."""

cldk/analysis/typescript/codeanalyzer/codeanalyzer.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from collections import deque
3434
from pathlib import Path
3535
from subprocess import CompletedProcess
36-
from typing import Dict, List, Set, Tuple, Union
36+
from typing import Dict, Iterator, List, Set, Tuple, Union
3737

3838
import networkx as nx
3939

@@ -42,6 +42,7 @@
4242
from cldk.models.typescript import (
4343
TSApplication,
4444
TSCallable,
45+
TSCallableOverview,
4546
TSCallsite,
4647
TSClass,
4748
TSClassAttribute,
@@ -554,3 +555,57 @@ def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[s
554555
if dec.name in wanted:
555556
result[dec.name].append(sig)
556557
return result
558+
559+
# -----[ bulk / projected accessors ]-----
560+
def _iter_callables(self) -> Iterator[Tuple[TSCallable, str | None, str | None]]:
561+
"""Yield ``(callable, owner_signature, owner_kind)`` for every callable in the
562+
application, including inner/nested callables. The owner map is built only from
563+
``_methods_by_class`` keyed against ``_classes``/``_interfaces``: namespace-owned
564+
functions and module-level/nested callables are never in that map, so they correctly come
565+
out owner-less (None, None), per the closed "class"|"interface" owner_kind set."""
566+
owner_of: Dict[str, Tuple[str, str]] = {}
567+
for owner_sig, methods in self._methods_by_class.items():
568+
if owner_sig in self._classes:
569+
owner_kind = "class"
570+
elif owner_sig in self._interfaces:
571+
owner_kind = "interface"
572+
else:
573+
continue
574+
for m in methods.values():
575+
owner_of[m.signature] = (owner_sig, owner_kind)
576+
for sig, c in self._callables.items():
577+
owner_sig, owner_kind = owner_of.get(sig, (None, None))
578+
yield c, owner_sig, owner_kind
579+
580+
def get_callables_overview(self) -> List[TSCallableOverview]:
581+
"""Return a lightweight overview of every callable in the application (see
582+
:meth:`TSAnalysisBackend.get_callables_overview`)."""
583+
return [TSCallableOverview.from_callable(c, owner_sig, owner_kind) for c, owner_sig, owner_kind in self._iter_callables()]
584+
585+
def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
586+
"""Return ``{signature: code}`` for the requested signatures that exist and have a body
587+
(omits callables whose ``code`` is ``None``, e.g. implicit constructors)."""
588+
result: Dict[str, str] = {}
589+
for sig in signatures:
590+
c = self._callables.get(sig)
591+
if c is not None and c.code is not None:
592+
result[sig] = c.code
593+
return result
594+
595+
def get_decorated_callables(self, markers: List[str]) -> List[TSCallableOverview]:
596+
"""Return overviews of callables decorated with any of ``markers``."""
597+
marker_set = set(markers)
598+
return [
599+
TSCallableOverview.from_callable(c, owner_sig, owner_kind)
600+
for c, owner_sig, owner_kind in self._iter_callables()
601+
if marker_set.intersection(d.name for d in c.decorators)
602+
]
603+
604+
def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[TSCallsite]]:
605+
"""Return ``{signature: call_sites}`` for the requested signatures that exist."""
606+
result: Dict[str, List[TSCallsite]] = {}
607+
for sig in signatures:
608+
c = self._callables.get(sig)
609+
if c is not None:
610+
result[sig] = list(c.call_sites)
611+
return result

cldk/analysis/typescript/neo4j/neo4j_backend.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from cldk.models.typescript import (
6464
TSApplication,
6565
TSCallable,
66+
TSCallableOverview,
6667
TSCallEdge,
6768
TSCallsite,
6869
TSClass,
@@ -710,3 +711,66 @@ def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[s
710711
for r in rows:
711712
result[r["dn"]].append(r["sig"])
712713
return result
714+
715+
# -----[ bulk / projected accessors ]-----
716+
# Field-projected RETURNs that sidestep the per-entity reconstruction fan-out: each is a single
717+
# Cypher statement (one round trip), not the child-fetch walk _callable_full pays.
718+
#
719+
# Owner leg: (o:Symbol)-[:HAS_METHOD]->(c) only ever connects a Class/Interface owner to one of
720+
# its methods, so it naturally has no match for module-level, namespace-owned, or nested
721+
# callables -- they fall out owner-less (None/None) with no separate namespace leg needed.
722+
_OVERVIEW_RETURN = (
723+
"OPTIONAL MATCH (o:Symbol)-[:HAS_METHOD]->(c) "
724+
"OPTIONAL MATCH (c)-[:DECORATED_BY]->(d:Decorator) "
725+
"RETURN c.signature AS signature, c.name AS name, c.kind AS kind, c.path AS path, "
726+
"c.start_line AS start_line, c.end_line AS end_line, "
727+
"c.is_exported AS is_exported, c.is_async AS is_async, c.is_static AS is_static, "
728+
"c.accessibility AS accessibility, "
729+
"o.signature AS owner_signature, labels(o) AS owner_labels, "
730+
"collect(DISTINCT d.name) AS decorators"
731+
)
732+
733+
def get_callables_overview(self) -> List[TSCallableOverview]:
734+
rows = self._run(
735+
"MATCH (c:Callable) WHERE c._module IN $mods " + self._OVERVIEW_RETURN,
736+
mods=self._modules,
737+
)
738+
return [R.overview(r) for r in rows]
739+
740+
def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
741+
rows = self._run(
742+
"MATCH (c:Callable) WHERE c._module IN $mods AND c.signature IN $sigs AND c.code IS NOT NULL "
743+
"RETURN c.signature AS signature, c.code AS code",
744+
mods=self._modules,
745+
sigs=list(signatures),
746+
)
747+
return {r["signature"]: r["code"] for r in rows}
748+
749+
def get_decorated_callables(self, markers: List[str]) -> List[TSCallableOverview]:
750+
rows = self._run(
751+
"MATCH (c:Callable)-[:DECORATED_BY]->(marker:Decorator) "
752+
"WHERE c._module IN $mods AND marker.name IN $markers "
753+
"WITH DISTINCT c " + self._OVERVIEW_RETURN,
754+
mods=self._modules,
755+
markers=list(markers),
756+
)
757+
return [R.overview(r) for r in rows]
758+
759+
def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[TSCallsite]]:
760+
# OPTIONAL MATCH so a requested callable with no call sites still yields a row (p is null),
761+
# giving it an empty-list entry -- parity with the in-process backend, which keys every
762+
# existing signature. ORDER mirrors _callsites_of's call-site ordering.
763+
rows = self._run(
764+
"MATCH (c:Callable) WHERE c._module IN $mods AND c.signature IN $sigs "
765+
"OPTIONAL MATCH (c)-[:HAS_CALLSITE]->(cs:CallSite) "
766+
"RETURN c.signature AS owner, properties(cs) AS p "
767+
"ORDER BY cs.start_line, cs.start_column",
768+
mods=self._modules,
769+
sigs=list(signatures),
770+
)
771+
out: Dict[str, List[TSCallsite]] = {}
772+
for r in rows:
773+
sites = out.setdefault(r["owner"], [])
774+
if r["p"] is not None:
775+
sites.append(R.callsite(r["p"]))
776+
return out

cldk/analysis/typescript/neo4j/reconstruct.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
from cldk.models.typescript import (
4242
TSCallable,
43+
TSCallableOverview,
4344
TSCallableParameter,
4445
TSCallsite,
4546
TSClass,
@@ -191,6 +192,45 @@ def synthesized(props: Props) -> TSSynthesizedCallable:
191192
)
192193

193194

195+
def overview(row: Props) -> TSCallableOverview:
196+
"""Build a :class:`TSCallableOverview` from a projected callable row (a flat ``RETURN``
197+
projection, not a node's ``properties()``): ``signature``/``name``/``kind``/``path``/
198+
``start_line``/``end_line``/``is_exported``/``is_async``/``is_static``/``accessibility`` plus
199+
``owner_signature``/``owner_labels`` (the ``HAS_METHOD`` owner leg — absent, i.e. both null,
200+
for module-level/namespace-owned/nested callables) and ``decorators`` (collected decorator
201+
names).
202+
203+
``owner_kind`` is derived from ``owner_labels`` rather than stored directly: ``"class"`` if the
204+
owner node carries the ``Class`` label, ``"interface"`` if it carries ``Interface``, else
205+
``None`` — matching the closed two-value ``owner_kind`` set the in-memory backend produces.
206+
"""
207+
owner_signature = row.get("owner_signature")
208+
owner_labels = row.get("owner_labels") or []
209+
if owner_signature is None:
210+
owner_kind = None
211+
elif "Class" in owner_labels:
212+
owner_kind = "class"
213+
elif "Interface" in owner_labels:
214+
owner_kind = "interface"
215+
else:
216+
owner_kind = None
217+
return TSCallableOverview(
218+
signature=row.get("signature", ""),
219+
name=row.get("name", ""),
220+
owner_signature=owner_signature,
221+
owner_kind=owner_kind,
222+
kind=row.get("kind", "function"),
223+
path=row.get("path", ""),
224+
start_line=row.get("start_line", -1),
225+
end_line=row.get("end_line", -1),
226+
decorators=[d for d in (row.get("decorators") or []) if d is not None],
227+
is_exported=bool(row.get("is_exported", False)),
228+
is_async=bool(row.get("is_async", False)),
229+
is_static=bool(row.get("is_static", False)),
230+
accessibility=row.get("accessibility"),
231+
)
232+
233+
194234
# ----------------------------------------------------------------------------------------------
195235
# declaration nodes (children supplied by the backend)
196236
# ----------------------------------------------------------------------------------------------

cldk/analysis/typescript/typescript_analysis.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from cldk.models.typescript import (
3737
TSApplication,
3838
TSCallable,
39+
TSCallableOverview,
3940
TSCallsite,
4041
TSClass,
4142
TSClassAttribute,
@@ -297,3 +298,75 @@ def get_methods_with_decorators(self, decorators: List[str]) -> Dict[str, List[s
297298
def get_classes_with_decorators(self, decorators: List[str]) -> Dict[str, List[str]]:
298299
"""Map each requested decorator name to the signatures of classes carrying it."""
299300
return self.backend.get_classes_with_decorators(decorators)
301+
302+
# -----[ bulk / projected accessors ]-----
303+
def get_callables_overview(self) -> List[TSCallableOverview]:
304+
"""Return a lightweight overview of every callable in the project, in one bulk read.
305+
306+
A field-projected alternative to :meth:`get_methods` for enumeration: each
307+
:class:`~cldk.models.typescript.TSCallableOverview` carries the callable's signature,
308+
owning class/interface (if any), native kind, location, and decorators — but not the full
309+
reconstruction (call sites, inner callables, locals). On the Neo4j backend this is a single
310+
Cypher query instead of the per-entity fan-out :meth:`get_methods` pays. Body-inspect the
311+
few you need afterwards via :meth:`get_method` or :meth:`get_method_bodies`.
312+
313+
Returns:
314+
A flat list of :class:`~cldk.models.typescript.TSCallableOverview`, one per callable
315+
(class/interface methods, module- and namespace-level functions, and nested/inner
316+
callables).
317+
318+
See Also:
319+
:meth:`get_decorated_callables`: The same projection filtered by decorator.
320+
:meth:`get_method_bodies`: Bulk source-body fetch for chosen signatures.
321+
322+
Note:
323+
A ``get x()``/``set x()`` accessor pair shares one ``signature``, so this projection
324+
(and the other bulk accessors) can diverge between the local and Neo4j backends on a
325+
paired accessor — see `#300 <https://github.com/codellm-devkit/python-sdk/issues/300>`_.
326+
"""
327+
return self.backend.get_callables_overview()
328+
329+
def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
330+
"""Return source bodies for the given callable signatures, in one bulk read.
331+
332+
Args:
333+
signatures: Callable signatures to fetch bodies for (e.g. from
334+
:meth:`get_callables_overview`).
335+
336+
Returns:
337+
A dict mapping each signature to its source body. Signatures with no matching callable
338+
are omitted, as are callables whose ``code`` is ``None`` (e.g. implicit constructors
339+
the analyzer synthesizes with no source text) — every returned value is a real ``str``.
340+
"""
341+
return self.backend.get_method_bodies(signatures)
342+
343+
def get_decorated_callables(self, markers: List[str]) -> List[TSCallableOverview]:
344+
"""Return overviews of callables decorated with any of the given markers, in one bulk read.
345+
346+
Args:
347+
markers: Decorator names to match (e.g. ``["Get", "Controller"]``).
348+
349+
Returns:
350+
A list of :class:`~cldk.models.typescript.TSCallableOverview` for every callable
351+
carrying at least one of ``markers`` as a decorator.
352+
353+
See Also:
354+
:meth:`get_callables_overview`: The unfiltered projection.
355+
"""
356+
return self.backend.get_decorated_callables(markers)
357+
358+
def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[TSCallsite]]:
359+
"""Return the call sites of the given callables, keyed by signature, in one bulk read.
360+
361+
Avoids the per-callable reconstruction fan-out when you need call sites for a specific
362+
frontier (e.g. dispatch-edge synthesis or external-reader detection).
363+
364+
Args:
365+
signatures: Callable signatures to fetch call sites for.
366+
367+
Returns:
368+
A dict mapping each existing signature to its list of
369+
:class:`~cldk.models.typescript.TSCallsite` (empty if the callable has no call sites).
370+
Signatures with no matching callable are omitted.
371+
"""
372+
return self.backend.get_callsites_for(signatures)

cldk/models/typescript/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@
4242
TSTypeParameter,
4343
TSVariableDeclaration,
4444
)
45+
from .projections import TSCallableOverview
4546

4647
__all__ = [
4748
"TSApplication",
4849
"TSCallEdge",
4950
"TSCallable",
51+
"TSCallableOverview",
5052
"TSCallableParameter",
5153
"TSCallsite",
5254
"TSClass",

0 commit comments

Comments
 (0)