diff --git a/README.md b/README.md index b883948..44f202e 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ analysis = CLDK.python( classes = analysis.get_all_classes() ``` +> **`project_path` with the Neo4j backend:** it's **optional** — the graph is read over Bolt, so you can omit it as shown above. CLDK validates `project_path` only when you actually pass one (it must exist and be a directory, on every backend); passing `None` skips that check. Supply a real path only if you also need on-disk source access (e.g. file content/snippets) alongside the graph. + > **Deprecation:** the old `CLDK(language="java").analysis(...)` entry point still works as a thin compatibility shim (it emits a `DeprecationWarning`). Prefer the `CLDK.java()` / `CLDK.python()` / `CLDK.typescript()` factory methods. ## Supported Languages & Backends diff --git a/cldk/analysis/commons/backend_config.py b/cldk/analysis/commons/backend_config.py index a84050f..ed18e00 100644 --- a/cldk/analysis/commons/backend_config.py +++ b/cldk/analysis/commons/backend_config.py @@ -75,6 +75,22 @@ class PyCodeAnalyzerConfig(CodeAnalyzerConfig): use_ray: bool = False +@dataclass +class TSCodeAnalyzerConfig(CodeAnalyzerConfig): + """Select the in-process codeanalyzer backend for TypeScript. + + Adds the TypeScript-only call-graph knob on top of :class:`CodeAnalyzerConfig`. + + Attributes: + tsc_only: If ``True``, restrict the analyzer to the tsc resolver call graph by passing + ``--tsc-only`` (codeanalyzer-typescript >= 0.4.2). Defaults to ``False`` (let the + binary choose its default). This is the supported replacement for the obsolete + ``--call-graph-provider both``. + """ + + tsc_only: bool = False + + @dataclass class Neo4jConnectionConfig: """Select the read-only Neo4j-backed analysis backend. @@ -102,7 +118,7 @@ class Neo4jConnectionConfig: # Per-language discriminated unions the facades match on. JavaBackend = Union[CodeAnalyzerConfig, Neo4jConnectionConfig] PyBackend = Union[PyCodeAnalyzerConfig, Neo4jConnectionConfig] -TSBackend = Union[CodeAnalyzerConfig, Neo4jConnectionConfig] +TSBackend = Union[TSCodeAnalyzerConfig, CodeAnalyzerConfig, Neo4jConnectionConfig] def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path, None], language: str) -> Path | None: diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index 9f3a275..5d28024 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -55,6 +55,7 @@ TSImport, TSInterface, TSModule, + TSSynthesizedCallable, TSTypeAlias, TSVariableDeclaration, ) @@ -86,6 +87,11 @@ def get_modules(self) -> List[TSModule]: def get_external_symbols(self) -> Dict[str, TSExternalSymbol]: """Phantom (external) call targets — imported/required library members.""" + @abstractmethod + def get_synthesized_callables(self) -> Dict[str, TSSynthesizedCallable]: + """Anonymous-callback endpoints the symbol table never names (Jelly-resolved). Keyed by the + synthesized signature that ``call_graph`` edges reference. Empty for the ``tsc`` resolver.""" + @abstractmethod def get_typescript_file(self, qualified_name: str) -> str | None: """The file path declaring the symbol with the given signature.""" diff --git a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py index 012022c..353fe8e 100644 --- a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py @@ -54,6 +54,7 @@ TSInterface, TSModule, TSNamespace, + TSSynthesizedCallable, TSTypeAlias, TSVariableDeclaration, ) @@ -81,6 +82,9 @@ class hierarchy, call sites, entrypoints, decorators, ...). The :class:`TypeScri analysis_level: ``AnalysisLevel.symbol_table`` (1) or ``AnalysisLevel.call_graph`` (2). eager_analysis: If True, re-run the analyzer even if a cached ``analysis.json`` exists. target_files: Restrict analysis to these files (incremental). + tsc_only: If True, restrict the analyzer to the tsc resolver call graph by passing + ``--tsc-only`` (codeanalyzer-typescript >= 0.4.2). Defaults to False (let the binary + choose its default). Replaces reliance on the obsolete ``--call-graph-provider both``. """ def __init__( @@ -90,12 +94,14 @@ def __init__( analysis_level: str, eager_analysis: bool, target_files: List[str] | None, + tsc_only: bool = False, ) -> None: self.project_dir = project_dir self.analysis_json_path = analysis_json_path self.analysis_level = analysis_level self.eager_analysis = eager_analysis self.target_files = target_files + self.tsc_only = tsc_only self.application: TSApplication = self._init_codeanalyzer( analysis_level=1 if analysis_level == AnalysisLevel.symbol_table else 2 ) @@ -139,6 +145,11 @@ def _init_codeanalyzer(self, analysis_level: int = 1) -> TSApplication: if self.target_files: for tf in self.target_files: target_args += ["-t", str(tf).strip()] + # Restrict the call graph to the tsc resolver path when requested, replacing the obsolete + # `--call-graph-provider both`. The `--tsc-only` flag lands in codeanalyzer-typescript + # 0.4.2; older binaries reject it, so only opt in when running >= 0.4.2. + if self.tsc_only: + target_args += ["--tsc-only"] if self.analysis_json_path is None: # Read compact JSON from the stdout pipe. @@ -285,6 +296,11 @@ def get_modules(self) -> List[TSModule]: def get_external_symbols(self) -> Dict[str, TSExternalSymbol]: return self.application.external_symbols + def get_synthesized_callables(self) -> Dict[str, TSSynthesizedCallable]: + """Anonymous-callback endpoints Jelly resolves that the symbol table never names. Keyed by + the synthesized signature an edge's ``source``/``target`` references.""" + return self.application.synthesized_callables + def get_typescript_file(self, qualified_name: str) -> str | None: return self._file_of.get(qualified_name) @@ -293,8 +309,9 @@ def get_typescript_module(self, file_path: str) -> TSModule | None: # -----[ call graph ]----- def get_call_graph(self) -> nx.DiGraph: - """Build (and cache) a NetworkX DiGraph whose nodes are callable signatures (and phantom - external symbols) and whose edges are the identity-only call edges.""" + """Build (and cache) a NetworkX DiGraph whose nodes are callable signatures (plus phantom + external symbols and synthesized anonymous callbacks) and whose edges are the identity-only + call edges.""" if self._call_graph is not None: return self._call_graph graph = nx.DiGraph() @@ -303,6 +320,9 @@ def get_call_graph(self) -> nx.DiGraph: # Phantom (external) nodes so that import-attributed edges don't dangle. for sig, ext in self.application.external_symbols.items(): graph.add_node(sig, external=True, module=ext.module, name=ext.name) + # Synthesized anonymous-callback nodes so Jelly's anonymous edges don't dangle. + for sig, syn in self.application.synthesized_callables.items(): + graph.add_node(sig, external=False, synthesized=True, name=syn.name, path=syn.path) for edge in self.application.call_graph: graph.add_edge( edge.source, diff --git a/cldk/analysis/typescript/neo4j/neo4j_backend.py b/cldk/analysis/typescript/neo4j/neo4j_backend.py index 5eb2612..71f7fdd 100644 --- a/cldk/analysis/typescript/neo4j/neo4j_backend.py +++ b/cldk/analysis/typescript/neo4j/neo4j_backend.py @@ -75,6 +75,7 @@ TSImport, TSInterface, TSModule, + TSSynthesizedCallable, TSTypeAlias, TSVariableDeclaration, ) @@ -238,6 +239,7 @@ def get_application(self) -> TSApplication: symbol_table=self.get_symbol_table(), call_graph=self._call_edges(), external_symbols=self.get_external_symbols(), + synthesized_callables=self.get_synthesized_callables(), ) def get_symbol_table(self) -> Dict[str, TSModule]: @@ -262,6 +264,15 @@ def get_external_symbols(self) -> Dict[str, TSExternalSymbol]: ) return {r["p"]["signature"]: R.external(r["p"]) for r in rows} + def get_synthesized_callables(self) -> Dict[str, TSSynthesizedCallable]: + """Anonymous-callback endpoints minted as ``:AnonymousCallable`` nodes (keyed by signature), + scoped to this application's modules.""" + rows = self._run( + "MATCH (a:AnonymousCallable) WHERE a._module IN $mods RETURN DISTINCT properties(a) AS p", + mods=self._modules, + ) + return {r["p"]["signature"]: R.synthesized(r["p"]) for r in rows} + def get_typescript_file(self, qualified_name: str) -> str | None: rows = self._run( "MATCH (s:Symbol {signature: $sig}) WHERE s._module IN $mods RETURN s._module AS m LIMIT 1", diff --git a/cldk/analysis/typescript/neo4j/reconstruct.py b/cldk/analysis/typescript/neo4j/reconstruct.py index d168a4b..986e503 100644 --- a/cldk/analysis/typescript/neo4j/reconstruct.py +++ b/cldk/analysis/typescript/neo4j/reconstruct.py @@ -53,6 +53,7 @@ TSModule, TSNamespace, TSSymbol, + TSSynthesizedCallable, TSTypeAlias, TSTypeParameter, TSVariableDeclaration, @@ -180,6 +181,15 @@ def external(props: Props) -> TSExternalSymbol: ) +def synthesized(props: Props) -> TSSynthesizedCallable: + return TSSynthesizedCallable( + name=props.get("name", ""), + path=props.get("path", ""), + start_line=props.get("start_line", -1), + start_column=props.get("start_column", -1), + ) + + # ---------------------------------------------------------------------------------------------- # declaration nodes (children supplied by the backend) # ---------------------------------------------------------------------------------------------- diff --git a/cldk/analysis/typescript/typescript_analysis.py b/cldk/analysis/typescript/typescript_analysis.py index 3927145..eef691b 100644 --- a/cldk/analysis/typescript/typescript_analysis.py +++ b/cldk/analysis/typescript/typescript_analysis.py @@ -47,6 +47,7 @@ TSImport, TSInterface, TSModule, + TSSynthesizedCallable, TSTypeAlias, TSVariableDeclaration, ) @@ -102,6 +103,7 @@ def __init__( analysis_level=analysis_level, eager_analysis=eager_analysis, target_files=target_files, + tsc_only=getattr(self.backend_config, "tsc_only", False), ) self.application: TSApplication = self.backend.get_application() @@ -126,6 +128,12 @@ def get_external_symbols(self) -> Dict[str, TSExternalSymbol]: reachability.""" return self.backend.get_external_symbols() + def get_synthesized_callables(self) -> Dict[str, TSSynthesizedCallable]: + """The synthesized anonymous-callback endpoints the call graph points at — Jelly-resolved + callbacks the symbol table never names (keyed by their ``:`` signature). + Empty under the ``tsc``-only resolver. Materialized so anonymous call edges don't dangle.""" + return self.backend.get_synthesized_callables() + def get_call_graph_json(self) -> str: return self.backend.get_call_graph_json() diff --git a/cldk/core.py b/cldk/core.py index 485eb7c..8d8d327 100644 --- a/cldk/core.py +++ b/cldk/core.py @@ -55,6 +55,7 @@ PyBackend, PyCodeAnalyzerConfig, TSBackend, + TSCodeAnalyzerConfig, ) from cldk.analysis.commons.treesitter import TreesitterJava from cldk.analysis.python.python_analysis import PythonAnalysis @@ -66,10 +67,13 @@ def _normalize_project_path(project_path: str | Path | None) -> Path | None: - """Expand and resolve a project path, validating it is a directory. + """Expand, resolve, and validate a project path. - Returns ``None`` unchanged (the Neo4j backends read their graph out of band, so a project - directory is optional there). + Validation is keyed off the *path*, not the backend: any non-``None`` path is resolved and + must exist and be a directory, otherwise :class:`CldkInitializationException` is raised — this + holds on every backend, including Neo4j. ``None`` is returned unchanged and skips validation + entirely, because the Neo4j backends read their graph out of band (over Bolt), so a project + directory is optional there. """ if project_path is None: return None @@ -134,7 +138,10 @@ def java( """Create a Java analysis facade. Args: - project_path: Path to the Java project directory. + project_path: Path to the Java project directory. Optional only when ``backend`` is a + :class:`Neo4jConnectionConfig` (the graph is read out of band over Bolt). When + provided, the path is validated — it must exist and be a directory — regardless of + backend. source_code: Single Java source string (deprecated; pass ``project_path`` instead). analysis_level: Analysis depth (see :class:`~cldk.analysis.AnalysisLevel`). target_files: Restrict analysis to these files. @@ -181,7 +188,9 @@ def python( Args: project_path: Path to the Python project directory. Optional only when ``backend`` is a - :class:`Neo4jConnectionConfig` (the graph is populated out of band). + :class:`Neo4jConnectionConfig` (the graph is populated out of band over Bolt). When + provided, the path is validated — it must exist and be a directory — regardless of + backend. analysis_level: Analysis depth (see :class:`~cldk.analysis.AnalysisLevel`). target_files: Restrict analysis to these files. eager: Force regeneration of cached analysis. @@ -209,12 +218,16 @@ def typescript( Args: project_path: Path to the TypeScript project directory. Optional only when ``backend`` - is a :class:`Neo4jConnectionConfig` (the graph is populated out of band). + is a :class:`Neo4jConnectionConfig` (the graph is populated out of band over Bolt). + When provided, the path is validated — it must exist and be a directory — regardless + of backend. analysis_level: Analysis depth (see :class:`~cldk.analysis.AnalysisLevel`). target_files: Restrict analysis to these files. eager: Force regeneration of cached analysis. - backend: Backend configuration. Defaults to :class:`CodeAnalyzerConfig`; - pass a :class:`Neo4jConnectionConfig` to use the read-only Neo4j backend. + backend: Backend configuration. Defaults to :class:`CodeAnalyzerConfig`; pass a + :class:`TSCodeAnalyzerConfig` to set TypeScript-only knobs such as ``tsc_only`` + (passes ``--tsc-only``), or a :class:`Neo4jConnectionConfig` to use the read-only + Neo4j backend. """ return TypeScriptAnalysis( project_dir=_normalize_project_path(project_path), diff --git a/cldk/models/typescript/__init__.py b/cldk/models/typescript/__init__.py index f136327..93e82b9 100644 --- a/cldk/models/typescript/__init__.py +++ b/cldk/models/typescript/__init__.py @@ -37,6 +37,7 @@ TSNamespace, TSOverloadSignature, TSSymbol, + TSSynthesizedCallable, TSTypeAlias, TSTypeParameter, TSVariableDeclaration, @@ -63,6 +64,7 @@ "TSNamespace", "TSOverloadSignature", "TSSymbol", + "TSSynthesizedCallable", "TSTypeAlias", "TSTypeParameter", "TSVariableDeclaration", diff --git a/cldk/models/typescript/models.py b/cldk/models/typescript/models.py index 346caf9..07c8c64 100644 --- a/cldk/models/typescript/models.py +++ b/cldk/models/typescript/models.py @@ -415,6 +415,22 @@ class TSExternalSymbol(_Base): module: str +class TSSynthesizedCallable(_Base): + """A first-party anonymous callback that Jelly resolves as a call-graph endpoint but the symbol + table never names (the canonicalizer returns ``null`` for anonymous functions). Materialized so + that ``call_graph`` edges to anonymous callbacks don't dangle. + + Slim, like :class:`TSExternalSymbol`: the map key in ``TSApplication.synthesized_callables`` IS + the synthesized signature ``:``, so an edge's + ``source``/``target`` byte-matches it just like a real ``TSCallable.signature``. The ``tsc`` + resolver emits an empty map; Jelly (the union default) populates it.""" + + name: str # display name — always ""; the signature key carries the precise identity + path: str # owning module key (project-relative POSIX path WITH extension) + start_line: int + start_column: int + + class TSEntrypoint(_Base): """A framework entrypoint (populated by level-2 finders; empty for level 1). Embedded on the owning ``TSCallable``/``TSClass``, so it carries no signature/source_file of its own.""" @@ -432,6 +448,7 @@ class TSApplication(_Base): symbol_table: Dict[str, TSModule] call_graph: List[TSCallEdge] = [] external_symbols: Dict[str, TSExternalSymbol] = {} + synthesized_callables: Dict[str, TSSynthesizedCallable] = {} # Resolve forward references for the mutually-recursive models. diff --git a/pyproject.toml b/pyproject.toml index f745573..c29ab5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "clang==17.0.6", "libclang==17.0.6", "codeanalyzer-python==0.2.0", - "codeanalyzer-typescript==0.4.0", + "codeanalyzer-typescript==0.4.3", ] [project.optional-dependencies] @@ -89,7 +89,7 @@ include = [ [tool.backend-versions] codeanalyzer-java = "2.4.1" codeanalyzer-python = "0.2.0" -codeanalyzer-typescript = "0.4.0" +codeanalyzer-typescript = "0.4.3" ######################################## # Tool configurations