|
14 | 14 | # limitations under the License. |
15 | 15 | ################################################################################ |
16 | 16 |
|
17 | | -"""Neo4j-backed TypeScript analysis backend. |
| 17 | +"""Neo4j-backed TypeScript analysis backend (read-only Cypher client). |
18 | 18 |
|
19 | 19 | A drop-in alternative to :class:`TSCodeanalyzer`: it exposes the **same query |
20 | 20 | method surface** (``get_all_classes``, ``get_call_graph``, ``get_all_callers``, |
21 | 21 | ...) so the :class:`TypeScriptAnalysis` facade can delegate to either one, but |
22 | 22 | every method answers by running **Cypher over a live Neo4j graph** instead of |
23 | 23 | walking the in-memory pydantic/NetworkX structures. |
24 | 24 |
|
| 25 | +This class is purely a **query client**: it never builds the graph and has no |
| 26 | +dependency on the ``codeanalyzer-typescript`` binary or the project sources. It |
| 27 | +assumes the database is already populated and just polls it — the shape a cloud |
| 28 | +deployment wants, where a third-party job (e.g. inside Kubernetes) loads the |
| 29 | +graph out of band and the SDK only reads it. |
| 30 | +
|
25 | 31 | The graph is the one ``codeanalyzer-typescript`` emits with ``--emit neo4j`` |
26 | | -(schema: ``codeanalyzer-ts/schema.neo4j.json``). On construction this backend can |
27 | | -populate the database for you by shelling out to the analyzer binary with |
28 | | -``--emit neo4j --neo4j-uri ...`` (mirroring how :class:`TSCodeanalyzer` shells |
29 | | -out to produce ``analysis.json``); or you can point it at an already-loaded DB |
30 | | -with ``build_db=False``. |
| 32 | +(schema: ``codeanalyzer-ts/schema.neo4j.json``). Populating it is the job of |
| 33 | +:class:`~cldk.analysis.typescript.neo4j.TSNeo4jIngestor` (a local/dev |
| 34 | +convenience), not of this backend. |
31 | 35 |
|
32 | 36 | Identity model (must match the in-memory backend): |
33 | 37 |
|
|
50 | 54 | from __future__ import annotations |
51 | 55 |
|
52 | 56 | import logging |
53 | | -import os |
54 | | -import shlex |
55 | | -import subprocess |
56 | 57 | from collections import deque |
57 | | -from importlib import resources |
58 | | -from pathlib import Path |
59 | | -from typing import Any, Dict, List, Set, Tuple, Union |
| 58 | +from typing import Any, Dict, List, Set, Tuple |
60 | 59 |
|
61 | 60 | import networkx as nx |
62 | 61 |
|
63 | | -from cldk.analysis import AnalysisLevel |
64 | 62 | from cldk.analysis.typescript.backend import TSAnalysisBackend |
65 | 63 | from cldk.analysis.typescript.neo4j import reconstruct as R |
66 | 64 | from cldk.models.typescript import ( |
|
87 | 85 |
|
88 | 86 |
|
89 | 87 | class TSNeo4jBackend(TSAnalysisBackend): |
90 | | - """Build and query the application view of a TypeScript project over Neo4j (Cypher). |
| 88 | + """Query the application view of a TypeScript project over Neo4j (Cypher), read-only. |
| 89 | +
|
| 90 | + The graph must already be loaded — by :class:`TSNeo4jIngestor` on a dev machine, or by a |
| 91 | + third-party job in a cloud deployment. This backend never writes and needs neither the |
| 92 | + ``codeanalyzer-typescript`` binary nor the project sources on disk. |
91 | 93 |
|
92 | 94 | Args: |
93 | | - project_dir: Root of the TypeScript project (required when ``build_db`` is True). |
94 | | - analysis_backend_path: Directory containing the ``codeanalyzer-typescript`` binary. If |
95 | | - None, falls back to ``$CODEANALYZER_TS_BIN``, then the ``codeanalyzer_typescript`` |
96 | | - wheel, then a bundled binary. |
97 | | - analysis_level: ``AnalysisLevel.symbol_table`` (1) or ``AnalysisLevel.call_graph`` (2). |
98 | | - eager_analysis: If True, force a clean rebuild of the graph even if this application's |
99 | | - ``:Application`` anchor already exists in the database. |
100 | | - target_files: Restrict analysis to these files (incremental push). |
101 | 95 | neo4j_uri: Bolt URI of the Neo4j server (e.g. ``bolt://localhost:7687``). |
102 | | - neo4j_username / neo4j_password: Credentials. |
| 96 | + neo4j_username / neo4j_password: Credentials (read-only is sufficient). |
103 | 97 | neo4j_database: Database name (None ⇒ server default). |
104 | | - application_name: The ``:Application`` anchor name. Defaults to the project directory |
105 | | - name, matching ``codeanalyzer-typescript``'s ``--app-name`` default. |
106 | | - build_db: If True (default), populate the database from ``project_dir`` on construction. |
107 | | - If False, query an already-loaded graph (``project_dir`` may be None). |
| 98 | + application_name: The ``:Application`` anchor name to scope every query to. Matches the |
| 99 | + ``--app-name`` the graph was loaded with (defaults to the project directory name). |
108 | 100 | """ |
109 | 101 |
|
110 | 102 | def __init__( |
111 | 103 | self, |
112 | | - project_dir: Union[str, Path, None], |
113 | | - analysis_backend_path: Union[str, Path, None], |
114 | | - analysis_level: str, |
115 | | - eager_analysis: bool, |
116 | | - target_files: List[str] | None, |
117 | 104 | neo4j_uri: str, |
118 | 105 | neo4j_username: str, |
119 | 106 | neo4j_password: str, |
120 | 107 | neo4j_database: str | None = None, |
121 | 108 | application_name: str | None = None, |
122 | | - build_db: bool = True, |
123 | 109 | ) -> None: |
124 | 110 | try: |
125 | 111 | from neo4j import GraphDatabase |
126 | 112 | except ModuleNotFoundError as e: # pragma: no cover - import guard |
127 | 113 | raise CodeanalyzerExecutionException("The Neo4j backend requires the 'neo4j' driver. Install it with " "`pip install neo4j` (or `pip install cldk[neo4j]`).") from e |
128 | 114 |
|
129 | | - self.project_dir = project_dir |
130 | | - self.analysis_backend_path = analysis_backend_path |
131 | | - self.analysis_level = analysis_level |
132 | | - self.eager_analysis = eager_analysis |
133 | | - self.target_files = target_files |
134 | | - self.application_name = application_name or (Path(project_dir).name if project_dir else None) |
135 | | - if not self.application_name: |
136 | | - raise CodeanalyzerExecutionException("application_name could not be inferred; pass application_name explicitly when project_dir is None.") |
| 115 | + if not application_name: |
| 116 | + raise CodeanalyzerExecutionException("application_name is required to scope queries to an application.") |
| 117 | + self.application_name = application_name |
137 | 118 | self._database = neo4j_database |
138 | 119 | self._driver = GraphDatabase.driver(neo4j_uri, auth=(neo4j_username, neo4j_password)) |
139 | | - self._neo4j_conn = (neo4j_uri, neo4j_username, neo4j_password) |
140 | | - |
141 | | - if build_db: |
142 | | - if project_dir is None: |
143 | | - raise CodeanalyzerExecutionException("project_dir is required when build_db=True.") |
144 | | - self._build_graph() |
145 | 120 |
|
146 | 121 | # The application's module file_keys, used to scope every query to this app. |
147 | 122 | self._modules: List[str] = self._load_module_keys() |
@@ -169,88 +144,6 @@ def _load_module_keys(self) -> List[str]: |
169 | 144 | ) |
170 | 145 | return [r["k"] for r in rows] |
171 | 146 |
|
172 | | - # -----[ binary resolution + DB population ]----- |
173 | | - def _get_codeanalyzer_exec(self) -> List[str]: |
174 | | - """Resolve the codeanalyzer-typescript executable command (mirrors TSCodeanalyzer).""" |
175 | | - if self.analysis_backend_path: |
176 | | - backend = Path(self.analysis_backend_path) |
177 | | - binary = next( |
178 | | - (p for p in backend.rglob("codeanalyzer-typescript*") if p.is_file()), |
179 | | - None, |
180 | | - ) or next((p for p in backend.rglob("codeanalyzer-ts*") if p.is_file()), None) |
181 | | - if binary is None: |
182 | | - raise CodeanalyzerExecutionException("codeanalyzer-typescript binary not found in the provided analysis_backend_path.") |
183 | | - return [str(binary)] |
184 | | - |
185 | | - env_bin = os.environ.get("CODEANALYZER_TS_BIN") |
186 | | - if env_bin: |
187 | | - return shlex.split(env_bin) |
188 | | - |
189 | | - try: |
190 | | - import codeanalyzer_typescript |
191 | | - |
192 | | - return [str(codeanalyzer_typescript.bin_path())] |
193 | | - except (ModuleNotFoundError, FileNotFoundError): |
194 | | - pass |
195 | | - |
196 | | - try: |
197 | | - with resources.as_file(resources.files("cldk.analysis.typescript.codeanalyzer.bin")) as bin_dir: |
198 | | - binary = next((p for p in bin_dir.iterdir() if p.is_file() and p.name.startswith("codeanalyzer")), None) |
199 | | - if binary is not None: |
200 | | - return [str(binary)] |
201 | | - except (ModuleNotFoundError, FileNotFoundError): |
202 | | - pass |
203 | | - |
204 | | - raise CodeanalyzerExecutionException( |
205 | | - "codeanalyzer-typescript binary not found. Pass analysis_backend_path=<dir containing the " |
206 | | - "binary>, set $CODEANALYZER_TS_BIN, or bundle it under cldk/analysis/typescript/codeanalyzer/bin/." |
207 | | - ) |
208 | | - |
209 | | - def _build_graph(self) -> None: |
210 | | - """Push this project's graph into Neo4j via ``--emit neo4j --neo4j-uri`` (Bolt). |
211 | | -
|
212 | | - Lazy by default: if the ``:Application`` anchor already exists and ``eager_analysis`` is |
213 | | - False (and this is not a targeted/incremental run), the push is skipped. |
214 | | - """ |
215 | | - if not self.eager_analysis and not self.target_files and self._application_exists(): |
216 | | - logger.info("Neo4j already has application '%s'; skipping rebuild (lazy).", self.application_name) |
217 | | - return |
218 | | - |
219 | | - uri, user, password = self._neo4j_conn |
220 | | - level = 1 if self.analysis_level == AnalysisLevel.symbol_table else 2 |
221 | | - args = self._get_codeanalyzer_exec() + [ |
222 | | - "-i", |
223 | | - str(Path(self.project_dir)), |
224 | | - "-a", |
225 | | - str(level), |
226 | | - "--emit", |
227 | | - "neo4j", |
228 | | - "--neo4j-uri", |
229 | | - uri, |
230 | | - "--neo4j-user", |
231 | | - user, |
232 | | - "--neo4j-password", |
233 | | - password, |
234 | | - "--app-name", |
235 | | - self.application_name, |
236 | | - ] |
237 | | - if self._database: |
238 | | - args += ["--neo4j-database", self._database] |
239 | | - if self.eager_analysis: |
240 | | - args += ["--eager"] |
241 | | - for tf in self.target_files or []: |
242 | | - args += ["-t", str(tf).strip()] |
243 | | - |
244 | | - try: |
245 | | - logger.info("Running codeanalyzer-typescript (neo4j emit): %s", " ".join(args)) |
246 | | - subprocess.run(args, capture_output=True, text=True, check=True) |
247 | | - except Exception as e: # noqa: BLE001 |
248 | | - raise CodeanalyzerExecutionException(str(e)) from e |
249 | | - |
250 | | - def _application_exists(self) -> bool: |
251 | | - rows = self._run("MATCH (a:Application {name: $app}) RETURN count(a) AS c", app=self.application_name) |
252 | | - return bool(rows and rows[0]["c"] > 0) |
253 | | - |
254 | 147 | # -----[ child-fetch helpers (reconstruction) ]----- |
255 | 148 | def _decorators_of(self, signature: str) -> List[TSDecorator]: |
256 | 149 | rows = self._run( |
|
0 commit comments