java-codebase-rag 0.12.0__py3-none-any.whl → 0.12.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- java_codebase_rag-0.12.2.dist-info/METADATA +35 -0
- java_codebase_rag-0.12.2.dist-info/RECORD +4 -0
- {java_codebase_rag-0.12.0.dist-info → java_codebase_rag-0.12.2.dist-info}/WHEEL +1 -1
- java_codebase_rag/_deprecation.py +0 -103
- java_codebase_rag/_fdlimit.py +0 -56
- java_codebase_rag/_stdio.py +0 -32
- java_codebase_rag/_version.py +0 -35
- java_codebase_rag/absence/__init__.py +0 -0
- java_codebase_rag/absence/absence_diagnosis.py +0 -700
- java_codebase_rag/absence/absence_types.py +0 -124
- java_codebase_rag/absence/absence_vocab.py +0 -460
- java_codebase_rag/analysis/__init__.py +0 -0
- java_codebase_rag/analysis/pr_analysis.py +0 -563
- java_codebase_rag/analysis/resolve_service.py +0 -740
- java_codebase_rag/ast/__init__.py +0 -0
- java_codebase_rag/ast/ast_java.py +0 -2847
- java_codebase_rag/ast/ast_kotlin.py +0 -1794
- java_codebase_rag/ast/brownfield_events.py +0 -58
- java_codebase_rag/ast/chunk_heuristics.py +0 -83
- java_codebase_rag/ast/language.py +0 -117
- java_codebase_rag/cli.py +0 -1215
- java_codebase_rag/cli_dispatch.py +0 -251
- java_codebase_rag/cli_format.py +0 -85
- java_codebase_rag/cli_progress.py +0 -94
- java_codebase_rag/config.py +0 -833
- java_codebase_rag/eval/__init__.py +0 -1
- java_codebase_rag/eval/ground_truth.py +0 -100
- java_codebase_rag/eval/metrics.py +0 -107
- java_codebase_rag/eval/runner.py +0 -556
- java_codebase_rag/graph/__init__.py +0 -0
- java_codebase_rag/graph/build_ast_graph.py +0 -4593
- java_codebase_rag/graph/graph_enrich.py +0 -1940
- java_codebase_rag/graph/graph_types.py +0 -224
- java_codebase_rag/graph/java_ontology.py +0 -465
- java_codebase_rag/graph/ladybug_queries.py +0 -2213
- java_codebase_rag/graph/path_filtering.py +0 -509
- java_codebase_rag/index/__init__.py +0 -0
- java_codebase_rag/index/java_index_flow_lancedb.py +0 -879
- java_codebase_rag/index/java_index_v1_common.py +0 -33
- java_codebase_rag/install_data/__init__.py +0 -0
- java_codebase_rag/install_data/agents/explorer-rag-cli.md +0 -110
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +0 -152
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +0 -165
- java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +0 -107
- java_codebase_rag/installer.py +0 -2188
- java_codebase_rag/jrag.py +0 -4545
- java_codebase_rag/jrag_envelope.py +0 -1107
- java_codebase_rag/jrag_hints.py +0 -204
- java_codebase_rag/jrag_render.py +0 -926
- java_codebase_rag/lance_optimize.py +0 -264
- java_codebase_rag/mcp/__init__.py +0 -0
- java_codebase_rag/mcp/mcp_hints.py +0 -932
- java_codebase_rag/mcp/mcp_v2.py +0 -1916
- java_codebase_rag/mcp/server.py +0 -886
- java_codebase_rag/pipeline.py +0 -531
- java_codebase_rag/progress.py +0 -570
- java_codebase_rag/read_payloads.py +0 -781
- java_codebase_rag/search/__init__.py +0 -0
- java_codebase_rag/search/index_common.py +0 -10
- java_codebase_rag/search/search_lancedb.py +0 -1296
- java_codebase_rag/search/search_lexical.py +0 -449
- java_codebase_rag/search/search_scoring.py +0 -537
- java_codebase_rag/watch/__init__.py +0 -0
- java_codebase_rag/watch/client.py +0 -230
- java_codebase_rag/watch/daemon.py +0 -396
- java_codebase_rag/watch/lock.py +0 -201
- java_codebase_rag/watch/paths.py +0 -76
- java_codebase_rag/watch/protocol.py +0 -122
- java_codebase_rag/watch/server.py +0 -273
- java_codebase_rag/watch/warm.py +0 -105
- java_codebase_rag/watch/watcher.py +0 -394
- java_codebase_rag-0.12.0.dist-info/METADATA +0 -340
- java_codebase_rag-0.12.0.dist-info/RECORD +0 -75
- java_codebase_rag-0.12.0.dist-info/entry_points.txt +0 -5
- java_codebase_rag-0.12.0.dist-info/licenses/LICENSE +0 -21
- java_codebase_rag-0.12.0.dist-info/top_level.txt +0 -1
- /java_codebase_rag/__init__.py → /java_codebase_rag-0.12.2.dist-info/top_level.txt +0 -0
|
@@ -1,2213 +0,0 @@
|
|
|
1
|
-
"""Read-only Cypher helpers over the Ladybug AST graph built by `build_ast_graph.py`.
|
|
2
|
-
|
|
3
|
-
Each function opens a Ladybug connection on demand and returns plain JSON-ish dicts
|
|
4
|
-
so the MCP server can serialize them without further mapping.
|
|
5
|
-
|
|
6
|
-
The Ladybug database is opened read-only and cached per-process. This module is
|
|
7
|
-
intentionally dependency-light: nothing here imports LanceDB or sentence-transformers.
|
|
8
|
-
|
|
9
|
-
Cypher pitfalls (see also ``CLAUDE.md``): avoid ``label(e) IN $list`` in ``WHERE`` for
|
|
10
|
-
relationship-type filters; use OR of ``label(e) = $param`` with bound parameters.
|
|
11
|
-
Typed unions ``-[e:A|B]-`` require every ``RETURN`` column on ``e`` to exist on all
|
|
12
|
-
listed rel types, or the binder may fail.
|
|
13
|
-
"""
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import json
|
|
17
|
-
import logging
|
|
18
|
-
import os
|
|
19
|
-
import re
|
|
20
|
-
import threading
|
|
21
|
-
from dataclasses import asdict, dataclass
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
from typing import Any, Literal
|
|
24
|
-
|
|
25
|
-
import ladybug
|
|
26
|
-
|
|
27
|
-
from java_codebase_rag.ast.ast_java import ONTOLOGY_VERSION as _ONTOLOGY_VERSION
|
|
28
|
-
|
|
29
|
-
log = logging.getLogger(__name__)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _parse_ladybug_json(raw: str | None) -> dict[str, Any]:
|
|
33
|
-
"""Parse JSON from LadybugDB which returns unquoted keys like {key: value}.
|
|
34
|
-
|
|
35
|
-
Only quote keys at key positions (after ``{``, ``,`` or ``[``) so values
|
|
36
|
-
containing word-colon patterns (e.g. a URL ``https://...`` inside a quoted
|
|
37
|
-
string) are not corrupted. The previous ``(\\w+):`` regex matched ``word:``
|
|
38
|
-
anywhere, including inside values (issue #359).
|
|
39
|
-
"""
|
|
40
|
-
if not raw:
|
|
41
|
-
return {}
|
|
42
|
-
# Quote unquoted keys only where a key is expected: preceded by '{', ',' or
|
|
43
|
-
# '[' (with optional whitespace). This leaves word-colon runs inside values
|
|
44
|
-
# untouched.
|
|
45
|
-
quoted = re.sub(r'([,{\[]\s*)(\w+):', lambda m: f'{m.group(1)}"{m.group(2)}":', raw)
|
|
46
|
-
try:
|
|
47
|
-
return json.loads(quoted)
|
|
48
|
-
except Exception:
|
|
49
|
-
try:
|
|
50
|
-
# Fallback: try parsing as-is (for standard JSON).
|
|
51
|
-
return json.loads(raw)
|
|
52
|
-
except Exception:
|
|
53
|
-
log.warning("Failed to parse graph_meta JSON blob: %s", raw[:100])
|
|
54
|
-
return {}
|
|
55
|
-
|
|
56
|
-
# Composed describe / neighbors dot-keys (not stored graph edge labels).
|
|
57
|
-
_MEMBER_EDGE_COMPOSED_REL_MAP: tuple[tuple[str, str], ...] = (
|
|
58
|
-
("DECLARES.DECLARES_CLIENT", "DECLARES_CLIENT"),
|
|
59
|
-
("DECLARES.DECLARES_PRODUCER", "DECLARES_PRODUCER"),
|
|
60
|
-
("DECLARES.EXPOSES", "EXPOSES"),
|
|
61
|
-
)
|
|
62
|
-
_MEMBER_EDGE_COMPOSED_REL_BY_KEY: dict[str, str] = dict(_MEMBER_EDGE_COMPOSED_REL_MAP)
|
|
63
|
-
|
|
64
|
-
_OVERRIDE_AXIS_COMPOSED_REL_MAP: tuple[tuple[str, str | None], ...] = (
|
|
65
|
-
("OVERRIDDEN_BY", None),
|
|
66
|
-
("OVERRIDDEN_BY.DECLARES_CLIENT", "DECLARES_CLIENT"),
|
|
67
|
-
("OVERRIDDEN_BY.DECLARES_PRODUCER", "DECLARES_PRODUCER"),
|
|
68
|
-
("OVERRIDDEN_BY.EXPOSES", "EXPOSES"),
|
|
69
|
-
)
|
|
70
|
-
_OVERRIDE_AXIS_COMPOSED_REL_BY_KEY: dict[str, str | None] = dict(_OVERRIDE_AXIS_COMPOSED_REL_MAP)
|
|
71
|
-
OVERRIDE_AXIS_COMPOSED_EDGE_TYPES: frozenset[str] = frozenset(_OVERRIDE_AXIS_COMPOSED_REL_BY_KEY)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _coerce_id_list(raw: Any) -> list[str]:
|
|
75
|
-
"""Normalize Ladybug ``collect(DISTINCT ...)`` list results to string ids."""
|
|
76
|
-
if raw is None:
|
|
77
|
-
return []
|
|
78
|
-
if isinstance(raw, list):
|
|
79
|
-
return [str(x) for x in raw if x is not None and str(x) != ""]
|
|
80
|
-
s = str(raw)
|
|
81
|
-
return [s] if s else []
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
__all__ = [
|
|
85
|
-
"LadybugGraph",
|
|
86
|
-
"resolve_ladybug_path",
|
|
87
|
-
"SymbolHit",
|
|
88
|
-
"EdgeHit",
|
|
89
|
-
"CallEdge",
|
|
90
|
-
"ViaEdge",
|
|
91
|
-
"StageSymbol",
|
|
92
|
-
"RouteCaller",
|
|
93
|
-
"find_symbols_in_file_range",
|
|
94
|
-
]
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def resolve_ladybug_path(explicit: str | None = None) -> str:
|
|
98
|
-
"""Resolve the Ladybug DB path the same way the builder does."""
|
|
99
|
-
if explicit:
|
|
100
|
-
return str(Path(explicit).expanduser())
|
|
101
|
-
idx = os.environ.get("JAVA_CODEBASE_RAG_INDEX_DIR", "").strip()
|
|
102
|
-
if idx and not idx.startswith(("s3://", "gs://", "az://")):
|
|
103
|
-
return str(Path(os.path.expanduser(idx.rstrip("/"))) / "code_graph.lbug")
|
|
104
|
-
return str((Path.cwd() / ".java-codebase-rag" / "code_graph.lbug").resolve())
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
@dataclass
|
|
108
|
-
class SymbolHit:
|
|
109
|
-
id: str
|
|
110
|
-
kind: str
|
|
111
|
-
name: str
|
|
112
|
-
fqn: str
|
|
113
|
-
package: str
|
|
114
|
-
module: str
|
|
115
|
-
microservice: str
|
|
116
|
-
filename: str
|
|
117
|
-
start_line: int
|
|
118
|
-
end_line: int
|
|
119
|
-
start_byte: int
|
|
120
|
-
end_byte: int
|
|
121
|
-
modifiers: list[str]
|
|
122
|
-
annotations: list[str]
|
|
123
|
-
capabilities: list[str]
|
|
124
|
-
role: str
|
|
125
|
-
signature: str
|
|
126
|
-
parent_id: str
|
|
127
|
-
resolved: bool
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
@dataclass
|
|
131
|
-
class EdgeHit:
|
|
132
|
-
type: str # EXTENDS | IMPLEMENTS | INJECTS
|
|
133
|
-
src: SymbolHit
|
|
134
|
-
dst: SymbolHit
|
|
135
|
-
mechanism: str = ""
|
|
136
|
-
annotation: str = ""
|
|
137
|
-
field_or_param: str = ""
|
|
138
|
-
resolved: bool = True
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
@dataclass
|
|
142
|
-
class CallEdge:
|
|
143
|
-
src: SymbolHit
|
|
144
|
-
dst: SymbolHit
|
|
145
|
-
confidence: float
|
|
146
|
-
strategy: str
|
|
147
|
-
source: str
|
|
148
|
-
call_site_line: int
|
|
149
|
-
call_site_byte: int
|
|
150
|
-
arg_count: int
|
|
151
|
-
resolved: bool
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
@dataclass
|
|
155
|
-
class ViaEdge:
|
|
156
|
-
"""Labelled edge from a previous-stage node to a stage symbol.
|
|
157
|
-
|
|
158
|
-
Populated by `trace_flow` so callers can see *why* two types ended up
|
|
159
|
-
in the same chain (e.g. `INJECTS` vs `IMPLEMENTS` vs `CALLS`) and at what hop
|
|
160
|
-
from the frontier they were reached.
|
|
161
|
-
"""
|
|
162
|
-
edge_type: str # INJECTS | EXTENDS | IMPLEMENTS | CALLS | HTTP_CALLS | ASYNC_CALLS
|
|
163
|
-
from_fqn: str
|
|
164
|
-
hop: int # 1 = direct neighbour of previous-stage frontier
|
|
165
|
-
caller_node_id: str = "" # Client id when edge_type is HTTP_CALLS (SCHEMA v2)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
@dataclass
|
|
169
|
-
class StageSymbol:
|
|
170
|
-
"""A trace_flow stage entry: the symbol plus the edges that pulled it in.
|
|
171
|
-
|
|
172
|
-
Stage 0 (seeds) has `via=[]`. Later stages list every first-time path
|
|
173
|
-
from the previous frontier to `symbol`.
|
|
174
|
-
"""
|
|
175
|
-
symbol: SymbolHit
|
|
176
|
-
via: list[ViaEdge]
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
@dataclass
|
|
180
|
-
class RouteCaller:
|
|
181
|
-
caller_node_id: str
|
|
182
|
-
caller_node_kind: Literal["client", "producer"]
|
|
183
|
-
caller_microservice: str
|
|
184
|
-
declaring_symbol_id: str
|
|
185
|
-
confidence: float
|
|
186
|
-
match: str
|
|
187
|
-
declaring_symbol_fqn: str = ""
|
|
188
|
-
target_service: str = ""
|
|
189
|
-
raw_uri: str = ""
|
|
190
|
-
topic: str = ""
|
|
191
|
-
broker: str = ""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _symbol_return_for(alias: str) -> str:
|
|
195
|
-
"""Ladybug RETURN projection for Symbol properties, using the given node alias.
|
|
196
|
-
|
|
197
|
-
Centralised so queries that bind Symbol under a non-`s` alias (e.g. `n` in
|
|
198
|
-
graph-expansion / flow-tracing) don't emit `s.*` references that Ladybug
|
|
199
|
-
rejects with `Variable s is not in scope`.
|
|
200
|
-
"""
|
|
201
|
-
return (
|
|
202
|
-
f"{alias}.id AS id, {alias}.kind AS kind, {alias}.name AS name, {alias}.fqn AS fqn, "
|
|
203
|
-
f"{alias}.package AS package, {alias}.module AS module, "
|
|
204
|
-
f"{alias}.microservice AS microservice, {alias}.filename AS filename, "
|
|
205
|
-
f"{alias}.start_line AS start_line, {alias}.end_line AS end_line, "
|
|
206
|
-
f"{alias}.start_byte AS start_byte, {alias}.end_byte AS end_byte, "
|
|
207
|
-
f"{alias}.modifiers AS modifiers, {alias}.annotations AS annotations, "
|
|
208
|
-
f"{alias}.capabilities AS capabilities, "
|
|
209
|
-
f"{alias}.role AS role, {alias}.signature AS signature, "
|
|
210
|
-
f"{alias}.parent_id AS parent_id, {alias}.resolved AS resolved, {alias}.generated AS generated, {alias}.generated_by AS generated_by"
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
_SYMBOL_RETURN = _symbol_return_for("s")
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def _scope_filters(
|
|
218
|
-
alias: str,
|
|
219
|
-
*,
|
|
220
|
-
module: str | None,
|
|
221
|
-
microservice: str | None,
|
|
222
|
-
params: dict[str, Any],
|
|
223
|
-
) -> list[str]:
|
|
224
|
-
"""Build module/microservice scoping predicates against a node alias.
|
|
225
|
-
|
|
226
|
-
Mutates `params` to bind `$module` / `$microservice` only when the
|
|
227
|
-
corresponding filter is set, so unused names don't leak into the
|
|
228
|
-
Ladybug plan.
|
|
229
|
-
"""
|
|
230
|
-
out: list[str] = []
|
|
231
|
-
if module:
|
|
232
|
-
params["module"] = module
|
|
233
|
-
out.append(f"{alias}.module = $module")
|
|
234
|
-
if microservice:
|
|
235
|
-
params["microservice"] = microservice
|
|
236
|
-
out.append(f"{alias}.microservice = $microservice")
|
|
237
|
-
return out
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
_EXTERNAL_PREFIXES = (
|
|
241
|
-
"java.",
|
|
242
|
-
"javax.",
|
|
243
|
-
"jakarta.",
|
|
244
|
-
"org.springframework.",
|
|
245
|
-
"lombok.",
|
|
246
|
-
)
|
|
247
|
-
|
|
248
|
-
_EDGE_TYPES: tuple[str, ...] = (
|
|
249
|
-
"EXTENDS",
|
|
250
|
-
"IMPLEMENTS",
|
|
251
|
-
"INJECTS",
|
|
252
|
-
"OVERRIDES",
|
|
253
|
-
"DECLARES",
|
|
254
|
-
"CALLS",
|
|
255
|
-
"EXPOSES",
|
|
256
|
-
"DECLARES_CLIENT",
|
|
257
|
-
"DECLARES_PRODUCER",
|
|
258
|
-
"HTTP_CALLS",
|
|
259
|
-
"ASYNC_CALLS",
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def _type_part_fqn(sym_fqn: str) -> str:
|
|
264
|
-
return sym_fqn.split("#", 1)[0]
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def _is_external_fqn(fqn: str) -> bool:
|
|
268
|
-
base = _type_part_fqn(fqn)
|
|
269
|
-
return any(base.startswith(p) for p in _EXTERNAL_PREFIXES)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def _row_to_symbol(row: dict[str, Any]) -> SymbolHit:
|
|
273
|
-
return SymbolHit(
|
|
274
|
-
id=row.get("id", "") or "",
|
|
275
|
-
kind=row.get("kind", "") or "",
|
|
276
|
-
name=row.get("name", "") or "",
|
|
277
|
-
fqn=row.get("fqn", "") or "",
|
|
278
|
-
package=row.get("package", "") or "",
|
|
279
|
-
module=row.get("module", "") or "",
|
|
280
|
-
microservice=row.get("microservice", "") or "",
|
|
281
|
-
filename=row.get("filename", "") or "",
|
|
282
|
-
start_line=int(row.get("start_line") or 0),
|
|
283
|
-
end_line=int(row.get("end_line") or 0),
|
|
284
|
-
start_byte=int(row.get("start_byte") or 0),
|
|
285
|
-
end_byte=int(row.get("end_byte") or 0),
|
|
286
|
-
modifiers=list(row.get("modifiers") or []),
|
|
287
|
-
annotations=list(row.get("annotations") or []),
|
|
288
|
-
capabilities=list(row.get("capabilities") or []),
|
|
289
|
-
role=row.get("role", "") or "",
|
|
290
|
-
signature=row.get("signature", "") or "",
|
|
291
|
-
parent_id=row.get("parent_id", "") or "",
|
|
292
|
-
resolved=bool(row.get("resolved", True)),
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
_SYM_COLS = (
|
|
297
|
-
"id", "kind", "name", "fqn", "package", "module", "microservice",
|
|
298
|
-
"filename", "start_line", "end_line", "start_byte", "end_byte",
|
|
299
|
-
"modifiers", "annotations", "capabilities", "role", "signature", "parent_id", "resolved", "generated", "generated_by",
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def find_symbols_in_file_range(
|
|
304
|
-
graph: "LadybugGraph",
|
|
305
|
-
*,
|
|
306
|
-
filename: str,
|
|
307
|
-
start_line: int,
|
|
308
|
-
end_line: int,
|
|
309
|
-
) -> list[SymbolHit]:
|
|
310
|
-
"""Return `Symbol` rows overlapping `[start_line, end_line]` in `filename` (1-based, inclusive)."""
|
|
311
|
-
if start_line < 1 or end_line < start_line:
|
|
312
|
-
return []
|
|
313
|
-
q = (
|
|
314
|
-
f"MATCH (s:Symbol) WHERE s.filename = $fn "
|
|
315
|
-
f"AND s.start_line <= $hmax AND s.end_line >= $hmin "
|
|
316
|
-
f"RETURN {_SYMBOL_RETURN} ORDER BY s.start_line, s.end_line"
|
|
317
|
-
)
|
|
318
|
-
params = {"fn": filename, "hmax": int(end_line), "hmin": int(start_line)}
|
|
319
|
-
return [_row_to_symbol(r) for r in graph._rows(q, params)]
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
def _prefixed_symbol_row(prefix: str, row: dict[str, Any]) -> dict[str, Any]:
|
|
323
|
-
p = f"{prefix}_"
|
|
324
|
-
return {k[len(p) :]: v for k, v in row.items() if k.startswith(p)}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
def _row_to_call_edge(row: dict[str, Any]) -> CallEdge:
|
|
328
|
-
return CallEdge(
|
|
329
|
-
src=_row_to_symbol(_prefixed_symbol_row("caller", row)),
|
|
330
|
-
dst=_row_to_symbol(_prefixed_symbol_row("callee", row)),
|
|
331
|
-
confidence=float(row.get("confidence") or 0.0),
|
|
332
|
-
strategy=str(row.get("strategy") or ""),
|
|
333
|
-
source=str(row.get("source") or "static"),
|
|
334
|
-
call_site_line=int(row.get("call_site_line") or 0),
|
|
335
|
-
call_site_byte=int(row.get("call_site_byte") or 0),
|
|
336
|
-
arg_count=int(row.get("arg_count") or 0),
|
|
337
|
-
resolved=bool(row.get("resolved", True)),
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def _call_graph_needle_phantom_arity_alt(needle: str) -> str | None:
|
|
342
|
-
"""Map ``Type#method(123)`` → ``Type#method(?)`` for phantom callee FQNs (D1)."""
|
|
343
|
-
if "#" not in needle:
|
|
344
|
-
return None
|
|
345
|
-
i = needle.rfind("(")
|
|
346
|
-
if i <= 0 or not needle.endswith(")"):
|
|
347
|
-
return None
|
|
348
|
-
inner = needle[i + 1 : -1]
|
|
349
|
-
if not inner.isdigit():
|
|
350
|
-
return None
|
|
351
|
-
return needle[:i] + "(?)"
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
class LadybugGraph:
|
|
355
|
-
"""Thin wrapper around a read-only Ladybug connection.
|
|
356
|
-
|
|
357
|
-
Safe to share across threads: we hold a single `Connection`, guarded by a lock.
|
|
358
|
-
"""
|
|
359
|
-
|
|
360
|
-
_lock = threading.Lock()
|
|
361
|
-
_instance: "LadybugGraph | None" = None
|
|
362
|
-
_instance_path: str | None = None
|
|
363
|
-
|
|
364
|
-
def __init__(self, db_path: str) -> None:
|
|
365
|
-
self.db_path = db_path
|
|
366
|
-
self._db = ladybug.Database(db_path, read_only=True)
|
|
367
|
-
self._conn = ladybug.Connection(self._db)
|
|
368
|
-
self._conn_lock = threading.Lock()
|
|
369
|
-
|
|
370
|
-
@classmethod
|
|
371
|
-
def get(cls, db_path: str | None = None) -> "LadybugGraph":
|
|
372
|
-
resolved = resolve_ladybug_path(db_path)
|
|
373
|
-
with cls._lock:
|
|
374
|
-
if cls._instance is None or cls._instance_path != resolved:
|
|
375
|
-
instance = cls(resolved)
|
|
376
|
-
meta = instance.meta()
|
|
377
|
-
graph_version = int(meta.get("ontology_version") or 0)
|
|
378
|
-
if "error" not in meta and graph_version < _ONTOLOGY_VERSION:
|
|
379
|
-
raise RuntimeError(
|
|
380
|
-
f"Graph ontology version {graph_version} is older than the "
|
|
381
|
-
f"required version {_ONTOLOGY_VERSION}. "
|
|
382
|
-
"Rebuild the graph: `python build_ast_graph.py --source-root <repo>`, "
|
|
383
|
-
"or run `jrag reprocess --source-root <repo>` for a full "
|
|
384
|
-
"Lance+Ladybug re-index."
|
|
385
|
-
)
|
|
386
|
-
cls._instance = instance
|
|
387
|
-
cls._instance_path = resolved
|
|
388
|
-
return cls._instance
|
|
389
|
-
|
|
390
|
-
@classmethod
|
|
391
|
-
def exists(cls, db_path: str | None = None) -> bool:
|
|
392
|
-
resolved = resolve_ladybug_path(db_path)
|
|
393
|
-
p = Path(resolved)
|
|
394
|
-
if not p.exists():
|
|
395
|
-
return False
|
|
396
|
-
# Ladybug represents DB as a directory; allow file form too (single-file DBs).
|
|
397
|
-
return True
|
|
398
|
-
|
|
399
|
-
@classmethod
|
|
400
|
-
def reset_for_path(cls, db_path: str | None) -> None:
|
|
401
|
-
"""Drop the cached singleton so the next ``get`` reopens.
|
|
402
|
-
|
|
403
|
-
Clears the cache when ``db_path is None`` (unconditional) or when ``db_path``
|
|
404
|
-
resolves to the currently cached instance path (resolved the same way
|
|
405
|
-
``get`` resolves it, so ``~``-relative / non-normalized paths match); a
|
|
406
|
-
non-matching ``db_path`` is a no-op. The watch daemon uses this to manage
|
|
407
|
-
its copy-on-write graph snapshot lifecycle (drop the original reader
|
|
408
|
-
before a subprocess reindex writes it; drop the sidecar reader on commit).
|
|
409
|
-
"""
|
|
410
|
-
with cls._lock:
|
|
411
|
-
if db_path is None or cls._instance_path == resolve_ladybug_path(db_path):
|
|
412
|
-
cls._instance = None
|
|
413
|
-
cls._instance_path = None
|
|
414
|
-
|
|
415
|
-
# ---- low-level ----
|
|
416
|
-
|
|
417
|
-
def _rows(self, query: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
418
|
-
with self._conn_lock:
|
|
419
|
-
r = self._conn.execute(query, params or {})
|
|
420
|
-
columns = r.get_column_names()
|
|
421
|
-
out: list[dict[str, Any]] = []
|
|
422
|
-
while r.has_next():
|
|
423
|
-
vals = r.get_next()
|
|
424
|
-
out.append(dict(zip(columns, vals)))
|
|
425
|
-
return out
|
|
426
|
-
|
|
427
|
-
# ---- meta ----
|
|
428
|
-
|
|
429
|
-
def meta(self) -> dict[str, Any]:
|
|
430
|
-
_META_PR_F1 = (
|
|
431
|
-
"MATCH (m:GraphMeta) RETURN m.key AS key, m.ontology_version AS ontology_version, "
|
|
432
|
-
"m.built_at AS built_at, m.source_root AS source_root, "
|
|
433
|
-
"m.counts_json AS counts_json, m.parse_errors AS parse_errors, "
|
|
434
|
-
"m.routes_total AS routes_total, m.exposes_total AS exposes_total, "
|
|
435
|
-
"m.routes_by_framework AS routes_by_framework, "
|
|
436
|
-
"m.routes_resolved_pct AS routes_resolved_pct, "
|
|
437
|
-
"m.routes_from_brownfield_pct AS routes_from_brownfield_pct, "
|
|
438
|
-
"m.routes_by_layer AS routes_by_layer, "
|
|
439
|
-
"m.http_calls_total AS http_calls_total, m.async_calls_total AS async_calls_total, "
|
|
440
|
-
"m.http_calls_by_strategy AS http_calls_by_strategy, m.async_calls_by_strategy AS async_calls_by_strategy, "
|
|
441
|
-
"m.http_calls_resolved_pct AS http_calls_resolved_pct, m.async_calls_resolved_pct AS async_calls_resolved_pct, "
|
|
442
|
-
"m.http_clients_from_brownfield_pct AS http_clients_from_brownfield_pct, "
|
|
443
|
-
"m.async_producers_from_brownfield_pct AS async_producers_from_brownfield_pct, "
|
|
444
|
-
"m.http_calls_match_breakdown AS http_calls_match_breakdown, "
|
|
445
|
-
"m.async_calls_match_breakdown AS async_calls_match_breakdown, "
|
|
446
|
-
"m.cross_service_calls_total AS cross_service_calls_total, "
|
|
447
|
-
"m.pass3_skipped_cross_service AS pass3_skipped_cross_service, "
|
|
448
|
-
"m.pass4_exposes_suppressed_feign AS pass4_exposes_suppressed_feign, "
|
|
449
|
-
"m.cross_service_resolution AS cross_service_resolution"
|
|
450
|
-
)
|
|
451
|
-
_META_PR_E3 = (
|
|
452
|
-
"MATCH (m:GraphMeta) RETURN m.key AS key, m.ontology_version AS ontology_version, "
|
|
453
|
-
"m.built_at AS built_at, m.source_root AS source_root, "
|
|
454
|
-
"m.counts_json AS counts_json, m.parse_errors AS parse_errors, "
|
|
455
|
-
"m.routes_total AS routes_total, m.exposes_total AS exposes_total, "
|
|
456
|
-
"m.routes_by_framework AS routes_by_framework, "
|
|
457
|
-
"m.routes_resolved_pct AS routes_resolved_pct, "
|
|
458
|
-
"m.routes_from_brownfield_pct AS routes_from_brownfield_pct, "
|
|
459
|
-
"m.routes_by_layer AS routes_by_layer, "
|
|
460
|
-
"m.http_calls_total AS http_calls_total, m.async_calls_total AS async_calls_total, "
|
|
461
|
-
"m.http_calls_by_strategy AS http_calls_by_strategy, m.async_calls_by_strategy AS async_calls_by_strategy, "
|
|
462
|
-
"m.http_calls_resolved_pct AS http_calls_resolved_pct, m.async_calls_resolved_pct AS async_calls_resolved_pct, "
|
|
463
|
-
"m.http_clients_from_brownfield_pct AS http_clients_from_brownfield_pct, "
|
|
464
|
-
"m.async_producers_from_brownfield_pct AS async_producers_from_brownfield_pct, "
|
|
465
|
-
"m.http_calls_match_breakdown AS http_calls_match_breakdown, "
|
|
466
|
-
"m.async_calls_match_breakdown AS async_calls_match_breakdown, "
|
|
467
|
-
"m.cross_service_calls_total AS cross_service_calls_total, "
|
|
468
|
-
"m.pass3_skipped_cross_service AS pass3_skipped_cross_service, "
|
|
469
|
-
"m.cross_service_resolution AS cross_service_resolution"
|
|
470
|
-
)
|
|
471
|
-
_META_PRE_E3 = (
|
|
472
|
-
"MATCH (m:GraphMeta) RETURN m.key AS key, m.ontology_version AS ontology_version, "
|
|
473
|
-
"m.built_at AS built_at, m.source_root AS source_root, "
|
|
474
|
-
"m.counts_json AS counts_json, m.parse_errors AS parse_errors, "
|
|
475
|
-
"m.routes_total AS routes_total, m.exposes_total AS exposes_total, "
|
|
476
|
-
"m.routes_by_framework AS routes_by_framework, "
|
|
477
|
-
"m.routes_resolved_pct AS routes_resolved_pct, "
|
|
478
|
-
"m.routes_from_brownfield_pct AS routes_from_brownfield_pct, "
|
|
479
|
-
"m.routes_by_layer AS routes_by_layer, "
|
|
480
|
-
"m.http_calls_total AS http_calls_total, m.async_calls_total AS async_calls_total, "
|
|
481
|
-
"m.http_calls_by_strategy AS http_calls_by_strategy, m.async_calls_by_strategy AS async_calls_by_strategy, "
|
|
482
|
-
"m.http_calls_resolved_pct AS http_calls_resolved_pct, m.async_calls_resolved_pct AS async_calls_resolved_pct, "
|
|
483
|
-
"m.http_clients_from_brownfield_pct AS http_clients_from_brownfield_pct, "
|
|
484
|
-
"m.async_producers_from_brownfield_pct AS async_producers_from_brownfield_pct, "
|
|
485
|
-
"m.http_calls_match_breakdown AS http_calls_match_breakdown, "
|
|
486
|
-
"m.async_calls_match_breakdown AS async_calls_match_breakdown, "
|
|
487
|
-
"m.cross_service_calls_total AS cross_service_calls_total"
|
|
488
|
-
)
|
|
489
|
-
_META_PR_A2 = (
|
|
490
|
-
"MATCH (m:GraphMeta) RETURN m.key AS key, m.ontology_version AS ontology_version, "
|
|
491
|
-
"m.built_at AS built_at, m.source_root AS source_root, "
|
|
492
|
-
"m.counts_json AS counts_json, m.parse_errors AS parse_errors, "
|
|
493
|
-
"m.routes_total AS routes_total, m.exposes_total AS exposes_total, "
|
|
494
|
-
"m.routes_by_framework AS routes_by_framework, "
|
|
495
|
-
"m.routes_resolved_pct AS routes_resolved_pct"
|
|
496
|
-
)
|
|
497
|
-
_META_LEGACY = (
|
|
498
|
-
"MATCH (m:GraphMeta) RETURN m.key AS key, m.ontology_version AS ontology_version, "
|
|
499
|
-
"m.built_at AS built_at, m.source_root AS source_root, "
|
|
500
|
-
"m.counts_json AS counts_json, m.parse_errors AS parse_errors"
|
|
501
|
-
)
|
|
502
|
-
rows: list[dict[str, Any]]
|
|
503
|
-
meta_mode = "pr_f1"
|
|
504
|
-
try:
|
|
505
|
-
rows = self._rows(_META_PR_F1)
|
|
506
|
-
except Exception:
|
|
507
|
-
meta_mode = "pr_e3"
|
|
508
|
-
try:
|
|
509
|
-
rows = self._rows(_META_PR_E3)
|
|
510
|
-
except Exception:
|
|
511
|
-
meta_mode = "pre_e3"
|
|
512
|
-
try:
|
|
513
|
-
rows = self._rows(_META_PRE_E3)
|
|
514
|
-
except Exception:
|
|
515
|
-
meta_mode = "pr_a2"
|
|
516
|
-
try:
|
|
517
|
-
rows = self._rows(_META_PR_A2)
|
|
518
|
-
except Exception:
|
|
519
|
-
meta_mode = "legacy"
|
|
520
|
-
try:
|
|
521
|
-
rows = self._rows(_META_LEGACY)
|
|
522
|
-
except Exception as e:
|
|
523
|
-
return {"error": f"{e}"}
|
|
524
|
-
if not rows:
|
|
525
|
-
return {"error": "no GraphMeta node"}
|
|
526
|
-
row = rows[0]
|
|
527
|
-
counts: dict[str, Any] = _parse_ladybug_json(row.get("counts_json"))
|
|
528
|
-
# Ensure counts has expected keys even if empty
|
|
529
|
-
if not counts:
|
|
530
|
-
counts = {
|
|
531
|
-
"packages": 0, "files": 0, "types": 0, "members": 0, "phantoms": 0,
|
|
532
|
-
"extends": 0, "implements": 0, "injects": 0, "declares": 0, "overrides": 0,
|
|
533
|
-
"calls": 0, "routes": 0, "exposes": 0, "clients": 0, "declares_client": 0,
|
|
534
|
-
"producers": 0, "declares_producer": 0, "http_calls": 0, "async_calls": 0,
|
|
535
|
-
}
|
|
536
|
-
routes_total = exposes_total = 0
|
|
537
|
-
routes_resolved_pct = 0.0
|
|
538
|
-
routes_by_framework: dict[str, Any] = {}
|
|
539
|
-
routes_from_brownfield_pct = 0.0
|
|
540
|
-
routes_by_layer: dict[str, Any] = {}
|
|
541
|
-
http_calls_total = 0
|
|
542
|
-
async_calls_total = 0
|
|
543
|
-
http_calls_by_strategy: dict[str, Any] = {}
|
|
544
|
-
async_calls_by_strategy: dict[str, Any] = {}
|
|
545
|
-
http_calls_resolved_pct = 0.0
|
|
546
|
-
async_calls_resolved_pct = 0.0
|
|
547
|
-
http_clients_from_brownfield_pct = 0.0
|
|
548
|
-
async_producers_from_brownfield_pct = 0.0
|
|
549
|
-
http_calls_match_breakdown: dict[str, Any] = {}
|
|
550
|
-
async_calls_match_breakdown: dict[str, Any] = {}
|
|
551
|
-
cross_service_calls_total = 0
|
|
552
|
-
pass3_skipped_cross_service = 0
|
|
553
|
-
pass4_exposes_suppressed_feign: int | None = None
|
|
554
|
-
cross_service_resolution: str | None = None
|
|
555
|
-
if meta_mode != "legacy":
|
|
556
|
-
rfw_raw = row.get("routes_by_framework") or "{}"
|
|
557
|
-
routes_by_framework = _parse_ladybug_json(rfw_raw) if isinstance(rfw_raw, str) else (rfw_raw or {})
|
|
558
|
-
if not isinstance(routes_by_framework, dict):
|
|
559
|
-
routes_by_framework = {}
|
|
560
|
-
routes_total = int(row.get("routes_total") or 0)
|
|
561
|
-
exposes_total = int(row.get("exposes_total") or 0)
|
|
562
|
-
routes_resolved_pct = float(row.get("routes_resolved_pct") or 0.0)
|
|
563
|
-
if meta_mode in ("pr_f1", "pr_e3", "pre_e3"):
|
|
564
|
-
routes_from_brownfield_pct = float(row.get("routes_from_brownfield_pct") or 0.0)
|
|
565
|
-
rbl_raw = row.get("routes_by_layer") or "{}"
|
|
566
|
-
routes_by_layer = _parse_ladybug_json(rbl_raw) if isinstance(rbl_raw, str) else (rbl_raw or {})
|
|
567
|
-
if not isinstance(routes_by_layer, dict):
|
|
568
|
-
routes_by_layer = {}
|
|
569
|
-
http_calls_total = int(row.get("http_calls_total") or 0)
|
|
570
|
-
async_calls_total = int(row.get("async_calls_total") or 0)
|
|
571
|
-
hbs_raw = row.get("http_calls_by_strategy") or "{}"
|
|
572
|
-
abs_raw = row.get("async_calls_by_strategy") or "{}"
|
|
573
|
-
http_calls_by_strategy = _parse_ladybug_json(hbs_raw) if isinstance(hbs_raw, str) else (hbs_raw or {})
|
|
574
|
-
if not isinstance(http_calls_by_strategy, dict):
|
|
575
|
-
http_calls_by_strategy = {}
|
|
576
|
-
async_calls_by_strategy = _parse_ladybug_json(abs_raw) if isinstance(abs_raw, str) else (abs_raw or {})
|
|
577
|
-
if not isinstance(async_calls_by_strategy, dict):
|
|
578
|
-
async_calls_by_strategy = {}
|
|
579
|
-
http_calls_resolved_pct = float(row.get("http_calls_resolved_pct") or 0.0)
|
|
580
|
-
async_calls_resolved_pct = float(row.get("async_calls_resolved_pct") or 0.0)
|
|
581
|
-
http_clients_from_brownfield_pct = float(row.get("http_clients_from_brownfield_pct") or 0.0)
|
|
582
|
-
async_producers_from_brownfield_pct = float(row.get("async_producers_from_brownfield_pct") or 0.0)
|
|
583
|
-
hmb_raw = row.get("http_calls_match_breakdown") or "{}"
|
|
584
|
-
amb_raw = row.get("async_calls_match_breakdown") or "{}"
|
|
585
|
-
http_calls_match_breakdown = _parse_ladybug_json(hmb_raw) if isinstance(hmb_raw, str) else (hmb_raw or {})
|
|
586
|
-
if not isinstance(http_calls_match_breakdown, dict):
|
|
587
|
-
http_calls_match_breakdown = {}
|
|
588
|
-
async_calls_match_breakdown = _parse_ladybug_json(amb_raw) if isinstance(amb_raw, str) else (amb_raw or {})
|
|
589
|
-
if not isinstance(async_calls_match_breakdown, dict):
|
|
590
|
-
async_calls_match_breakdown = {}
|
|
591
|
-
cross_service_calls_total = int(row.get("cross_service_calls_total") or 0)
|
|
592
|
-
pass3_skipped_cross_service = int(row.get("pass3_skipped_cross_service") or 0)
|
|
593
|
-
if meta_mode == "pr_f1":
|
|
594
|
-
pass4_exposes_suppressed_feign = int(row.get("pass4_exposes_suppressed_feign") or 0)
|
|
595
|
-
raw_csr = row.get("cross_service_resolution")
|
|
596
|
-
cross_service_resolution = (
|
|
597
|
-
str(raw_csr) if raw_csr not in (None, "") else None
|
|
598
|
-
)
|
|
599
|
-
elif meta_mode == "pr_e3":
|
|
600
|
-
raw_csr = row.get("cross_service_resolution")
|
|
601
|
-
cross_service_resolution = (
|
|
602
|
-
str(raw_csr) if raw_csr not in (None, "") else None
|
|
603
|
-
)
|
|
604
|
-
edge_counts = {edge: 0 for edge in _EDGE_TYPES}
|
|
605
|
-
failed_edges: list[str] = []
|
|
606
|
-
for edge_type in _EDGE_TYPES:
|
|
607
|
-
try:
|
|
608
|
-
edge_rows = self._rows(
|
|
609
|
-
f"MATCH ()-[e:{edge_type}]->() RETURN count(e) AS n"
|
|
610
|
-
)
|
|
611
|
-
edge_counts[edge_type] = int(edge_rows[0].get("n") or 0) if edge_rows else 0
|
|
612
|
-
except Exception as exc:
|
|
613
|
-
failed_edges.append(edge_type)
|
|
614
|
-
log.warning("edge count query failed for %s: %s", edge_type, exc)
|
|
615
|
-
if len(failed_edges) == len(_EDGE_TYPES):
|
|
616
|
-
log.warning("edge count queries failed for all edge types; returning zeroed edge_counts")
|
|
617
|
-
|
|
618
|
-
return {
|
|
619
|
-
"ontology_version": int(row.get("ontology_version") or 0),
|
|
620
|
-
"built_at": int(row.get("built_at") or 0),
|
|
621
|
-
"source_root": row.get("source_root") or "",
|
|
622
|
-
"parse_errors": int(row.get("parse_errors") or 0),
|
|
623
|
-
"counts": counts,
|
|
624
|
-
"routes_total": routes_total,
|
|
625
|
-
"exposes_total": exposes_total,
|
|
626
|
-
"routes_by_framework": routes_by_framework,
|
|
627
|
-
"routes_resolved_pct": routes_resolved_pct,
|
|
628
|
-
"routes_from_brownfield_pct": routes_from_brownfield_pct,
|
|
629
|
-
"routes_by_layer": routes_by_layer,
|
|
630
|
-
"http_calls_total": http_calls_total,
|
|
631
|
-
"async_calls_total": async_calls_total,
|
|
632
|
-
"http_calls_by_strategy": http_calls_by_strategy,
|
|
633
|
-
"async_calls_by_strategy": async_calls_by_strategy,
|
|
634
|
-
"http_calls_resolved_pct": http_calls_resolved_pct,
|
|
635
|
-
"async_calls_resolved_pct": async_calls_resolved_pct,
|
|
636
|
-
"http_clients_from_brownfield_pct": http_clients_from_brownfield_pct,
|
|
637
|
-
"async_producers_from_brownfield_pct": async_producers_from_brownfield_pct,
|
|
638
|
-
"http_calls_match_breakdown": http_calls_match_breakdown,
|
|
639
|
-
"async_calls_match_breakdown": async_calls_match_breakdown,
|
|
640
|
-
"cross_service_calls_total": cross_service_calls_total,
|
|
641
|
-
"pass3_skipped_cross_service": pass3_skipped_cross_service,
|
|
642
|
-
"pass4_exposes_suppressed_feign": pass4_exposes_suppressed_feign,
|
|
643
|
-
"cross_service_resolution": cross_service_resolution,
|
|
644
|
-
"edge_counts": edge_counts,
|
|
645
|
-
"db_path": self.db_path,
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
def edge_counts_for(self, node_id: str) -> dict[str, dict[str, int]]:
|
|
649
|
-
rows = self._rows(
|
|
650
|
-
"MATCH (n {id: $id})-[e]->() "
|
|
651
|
-
"RETURN label(e) AS edge_type, 'out' AS direction, count(e) AS n "
|
|
652
|
-
"UNION ALL "
|
|
653
|
-
"MATCH (n {id: $id})<-[e]-() "
|
|
654
|
-
"RETURN label(e) AS edge_type, 'in' AS direction, count(e) AS n",
|
|
655
|
-
{"id": node_id},
|
|
656
|
-
)
|
|
657
|
-
out: dict[str, dict[str, int]] = {}
|
|
658
|
-
for row in rows:
|
|
659
|
-
edge_type = str(row.get("edge_type") or "")
|
|
660
|
-
direction = str(row.get("direction") or "")
|
|
661
|
-
if edge_type == "" or direction not in ("in", "out"):
|
|
662
|
-
continue
|
|
663
|
-
out.setdefault(edge_type, {"in": 0, "out": 0})
|
|
664
|
-
out[edge_type][direction] = int(row.get("n") or 0)
|
|
665
|
-
return {
|
|
666
|
-
edge_type: dirs
|
|
667
|
-
for edge_type, dirs in out.items()
|
|
668
|
-
if int(dirs.get("in", 0)) > 0 or int(dirs.get("out", 0)) > 0
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
def member_edge_rollup_for(self, type_id: str) -> dict[str, dict[str, int]]:
|
|
672
|
-
"""2-hop DECLARES member edge counts for a type Symbol (describe-time only).
|
|
673
|
-
|
|
674
|
-
Keys use dot notation and are not stored graph edge labels.
|
|
675
|
-
"""
|
|
676
|
-
params = {"id": type_id}
|
|
677
|
-
rollup: dict[str, dict[str, int]] = {}
|
|
678
|
-
for key, rel in _MEMBER_EDGE_COMPOSED_REL_MAP:
|
|
679
|
-
rows = self._rows(
|
|
680
|
-
f"MATCH (t:Symbol {{id: $id}})-[:DECLARES]->(m:Symbol)-[e:{rel}]->() "
|
|
681
|
-
"RETURN count(e) AS n",
|
|
682
|
-
params,
|
|
683
|
-
)
|
|
684
|
-
n = sum(int(r.get("n") or 0) for r in rows) if rows else 0
|
|
685
|
-
if n > 0:
|
|
686
|
-
rollup[key] = {"in": 0, "out": n}
|
|
687
|
-
return rollup
|
|
688
|
-
|
|
689
|
-
def member_edge_traversal_for(self, type_id: str, composed_key: str) -> list[dict[str, Any]]:
|
|
690
|
-
"""2-hop DECLARES member traversal for a type Symbol (neighbors dot-key path)."""
|
|
691
|
-
rel = _MEMBER_EDGE_COMPOSED_REL_BY_KEY.get(composed_key)
|
|
692
|
-
if rel is None:
|
|
693
|
-
return []
|
|
694
|
-
# Untyped [e] + label(e) filter: typed unions fail the binder when RETURN references
|
|
695
|
-
# columns that exist on only some rel types (same pattern as flat neighbors_v2).
|
|
696
|
-
return self._rows(
|
|
697
|
-
"MATCH (t:Symbol {id: $id})-[:DECLARES]->(m:Symbol)-[e]->(term) "
|
|
698
|
-
"WHERE label(e) = $rel "
|
|
699
|
-
"RETURN m.id AS via_id, label(e) AS stored_edge_type, "
|
|
700
|
-
"term.id AS other_id, e.confidence AS confidence, e.strategy AS strategy, "
|
|
701
|
-
"e.match AS match, e.mechanism AS mechanism, e.annotation AS annotation, "
|
|
702
|
-
"e.field_or_param AS field_or_param, e.source AS source, "
|
|
703
|
-
"e.call_site_line AS call_site_line, e.call_site_byte AS call_site_byte, "
|
|
704
|
-
"e.arg_count AS arg_count, e.resolved AS resolved",
|
|
705
|
-
{"id": type_id, "rel": rel},
|
|
706
|
-
)
|
|
707
|
-
|
|
708
|
-
def override_axis_traversal_for(self, method_id: str, composed_key: str) -> list[dict[str, Any]]:
|
|
709
|
-
"""Override-axis composed traversal for a method Symbol (neighbors dot-key path).
|
|
710
|
-
|
|
711
|
-
Uses stored ``[:OVERRIDES]`` for the dispatch hop (aligned with ``override_axis_rollup_for``
|
|
712
|
-
overrider ids). Base key returns overrider method ids only; composed keys return terminal
|
|
713
|
-
rows with full edge attr projection plus ``via_id`` (overrider method id).
|
|
714
|
-
"""
|
|
715
|
-
rel = _OVERRIDE_AXIS_COMPOSED_REL_BY_KEY.get(composed_key)
|
|
716
|
-
if rel is None and composed_key != "OVERRIDDEN_BY":
|
|
717
|
-
return []
|
|
718
|
-
if rel is None:
|
|
719
|
-
return self._rows(
|
|
720
|
-
"MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol) "
|
|
721
|
-
"RETURN mover.id AS other_id",
|
|
722
|
-
{"id": method_id},
|
|
723
|
-
)
|
|
724
|
-
return self._rows(
|
|
725
|
-
"MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol)-[e]->(term) "
|
|
726
|
-
"WHERE label(e) = $rel "
|
|
727
|
-
"RETURN mover.id AS via_id, label(e) AS stored_edge_type, "
|
|
728
|
-
"term.id AS other_id, e.confidence AS confidence, e.strategy AS strategy, "
|
|
729
|
-
"e.match AS match, e.mechanism AS mechanism, e.annotation AS annotation, "
|
|
730
|
-
"e.field_or_param AS field_or_param, e.source AS source, "
|
|
731
|
-
"e.call_site_line AS call_site_line, e.call_site_byte AS call_site_byte, "
|
|
732
|
-
"e.arg_count AS arg_count, e.resolved AS resolved",
|
|
733
|
-
{"id": method_id, "rel": rel},
|
|
734
|
-
)
|
|
735
|
-
|
|
736
|
-
def count_calls_for_symbol(self, origin_id: str, *, direction: Literal["in", "out"]) -> int:
|
|
737
|
-
"""Count CALLS edges incident on a Symbol (hints / diagnostics)."""
|
|
738
|
-
if direction == "out":
|
|
739
|
-
pattern = "MATCH (origin:Symbol {id: $id})-[e:CALLS]->() RETURN count(e) AS n"
|
|
740
|
-
else:
|
|
741
|
-
pattern = "MATCH (origin:Symbol {id: $id})<-[e:CALLS]-() RETURN count(e) AS n"
|
|
742
|
-
rows = self._rows(pattern, {"id": origin_id})
|
|
743
|
-
return int(rows[0].get("n") or 0) if rows else 0
|
|
744
|
-
|
|
745
|
-
def neighbor_calls_for_symbol(
|
|
746
|
-
self,
|
|
747
|
-
origin_id: str,
|
|
748
|
-
*,
|
|
749
|
-
direction: Literal["in", "out"],
|
|
750
|
-
offset: int = 0,
|
|
751
|
-
limit: int | None = None,
|
|
752
|
-
sql_pagination: bool = True,
|
|
753
|
-
min_confidence: float | None = None,
|
|
754
|
-
include_strategies: list[str] | None = None,
|
|
755
|
-
exclude_strategies: list[str] | None = None,
|
|
756
|
-
callee_declaring_role: str | None = None,
|
|
757
|
-
callee_declaring_roles: list[str] | None = None,
|
|
758
|
-
exclude_callee_declaring_roles: list[str] | None = None,
|
|
759
|
-
) -> list[dict[str, Any]]:
|
|
760
|
-
"""CALLS neighbors with source-order delivery and optional edge-attribute pushdown.
|
|
761
|
-
|
|
762
|
-
When ``sql_pagination`` is True and ``limit`` is set, ``SKIP``/``LIMIT`` apply after
|
|
763
|
-
``ORDER BY e.call_site_line, e.call_site_byte``. Otherwise the full ordered stream is
|
|
764
|
-
returned for caller-side ``NodeFilter`` / pagination.
|
|
765
|
-
"""
|
|
766
|
-
wh_parts = ["origin.id = $id"]
|
|
767
|
-
params: dict[str, Any] = {"id": origin_id}
|
|
768
|
-
if min_confidence is not None:
|
|
769
|
-
wh_parts.append("e.confidence >= $min_confidence")
|
|
770
|
-
params["min_confidence"] = min_confidence
|
|
771
|
-
if include_strategies:
|
|
772
|
-
wh_parts.append("e.strategy IN $include_strategies")
|
|
773
|
-
params["include_strategies"] = include_strategies
|
|
774
|
-
if exclude_strategies:
|
|
775
|
-
wh_parts.append("NOT (e.strategy IN $exclude_strategies)")
|
|
776
|
-
params["exclude_strategies"] = exclude_strategies
|
|
777
|
-
if callee_declaring_role is not None:
|
|
778
|
-
wh_parts.append("e.callee_declaring_role = $callee_declaring_role")
|
|
779
|
-
params["callee_declaring_role"] = callee_declaring_role
|
|
780
|
-
if callee_declaring_roles:
|
|
781
|
-
wh_parts.append("e.callee_declaring_role IN $callee_declaring_roles")
|
|
782
|
-
params["callee_declaring_roles"] = callee_declaring_roles
|
|
783
|
-
if exclude_callee_declaring_roles:
|
|
784
|
-
wh_parts.append("NOT (e.callee_declaring_role IN $exclude_callee_declaring_roles)")
|
|
785
|
-
params["exclude_callee_declaring_roles"] = exclude_callee_declaring_roles
|
|
786
|
-
where = " AND ".join(wh_parts)
|
|
787
|
-
if direction == "out":
|
|
788
|
-
match = "MATCH (origin:Symbol)-[e:CALLS]->(other:Symbol)"
|
|
789
|
-
else:
|
|
790
|
-
match = "MATCH (origin:Symbol)<-[e:CALLS]-(other:Symbol)"
|
|
791
|
-
q = (
|
|
792
|
-
f"{match} WHERE {where} "
|
|
793
|
-
"RETURN other.id AS other_id, 'CALLS' AS edge_type, "
|
|
794
|
-
"e.confidence AS confidence, e.strategy AS strategy, e.source AS source, "
|
|
795
|
-
"e.call_site_line AS call_site_line, e.call_site_byte AS call_site_byte, "
|
|
796
|
-
"e.arg_count AS arg_count, e.resolved AS resolved, "
|
|
797
|
-
"e.callee_declaring_role AS callee_declaring_role "
|
|
798
|
-
"ORDER BY e.call_site_line, e.call_site_byte"
|
|
799
|
-
)
|
|
800
|
-
if sql_pagination and limit is not None:
|
|
801
|
-
q += " SKIP $offset LIMIT $limit"
|
|
802
|
-
params["offset"] = offset
|
|
803
|
-
params["limit"] = limit
|
|
804
|
-
return self._rows(q, params)
|
|
805
|
-
|
|
806
|
-
def count_unresolved_for_caller(self, caller_id: str) -> int:
|
|
807
|
-
rows = self._rows(
|
|
808
|
-
"MATCH (:Symbol {id: $id})-[:UNRESOLVED_AT]->(u:UnresolvedCallSite) "
|
|
809
|
-
"RETURN count(u) AS n",
|
|
810
|
-
{"id": caller_id},
|
|
811
|
-
)
|
|
812
|
-
return int(rows[0].get("n") or 0) if rows else 0
|
|
813
|
-
|
|
814
|
-
def unresolved_sites_for_caller(
|
|
815
|
-
self,
|
|
816
|
-
caller_id: str,
|
|
817
|
-
*,
|
|
818
|
-
direction: Literal["in", "out"] = "out",
|
|
819
|
-
) -> list[dict[str, Any]]:
|
|
820
|
-
if direction != "out":
|
|
821
|
-
return []
|
|
822
|
-
return self._rows(
|
|
823
|
-
"MATCH (:Symbol {id: $id})-[:UNRESOLVED_AT]->(u:UnresolvedCallSite) "
|
|
824
|
-
"RETURN u.id AS id, u.caller_id AS caller_id, u.call_site_line AS call_site_line, "
|
|
825
|
-
"u.call_site_byte AS call_site_byte, u.arg_count AS arg_count, "
|
|
826
|
-
"u.callee_simple AS callee_simple, u.receiver_expr AS receiver_expr, "
|
|
827
|
-
"u.reason AS reason "
|
|
828
|
-
"ORDER BY u.call_site_line, u.call_site_byte",
|
|
829
|
-
{"id": caller_id},
|
|
830
|
-
)
|
|
831
|
-
|
|
832
|
-
def unresolved_sites_for_describe(
|
|
833
|
-
self,
|
|
834
|
-
method_id: str,
|
|
835
|
-
*,
|
|
836
|
-
inline_limit: int = 5,
|
|
837
|
-
) -> tuple[list[dict[str, Any]], int]:
|
|
838
|
-
total_rows = self._rows(
|
|
839
|
-
"MATCH (:Symbol {id: $id})-[:UNRESOLVED_AT]->(u:UnresolvedCallSite) "
|
|
840
|
-
"RETURN count(u) AS n",
|
|
841
|
-
{"id": method_id},
|
|
842
|
-
)
|
|
843
|
-
total = int(total_rows[0].get("n") or 0) if total_rows else 0
|
|
844
|
-
if total == 0:
|
|
845
|
-
return [], 0
|
|
846
|
-
rows = self._rows(
|
|
847
|
-
"MATCH (:Symbol {id: $id})-[:UNRESOLVED_AT]->(u:UnresolvedCallSite) "
|
|
848
|
-
"RETURN u.call_site_line AS line, u.reason AS reason, "
|
|
849
|
-
"u.callee_simple AS callee_simple, u.receiver_expr AS receiver_expr "
|
|
850
|
-
"ORDER BY u.call_site_line, u.call_site_byte "
|
|
851
|
-
f"LIMIT {int(inline_limit)}",
|
|
852
|
-
{"id": method_id},
|
|
853
|
-
)
|
|
854
|
-
return rows, total
|
|
855
|
-
|
|
856
|
-
def list_unresolved_call_sites(
|
|
857
|
-
self,
|
|
858
|
-
*,
|
|
859
|
-
method_id: str | None = None,
|
|
860
|
-
reason: str | None = None,
|
|
861
|
-
microservice: str | None = None,
|
|
862
|
-
callee_simple: str | None = None,
|
|
863
|
-
limit: int = 100,
|
|
864
|
-
) -> list[dict[str, Any]]:
|
|
865
|
-
wh_parts: list[str] = []
|
|
866
|
-
params: dict[str, Any] = {"lim": int(limit)}
|
|
867
|
-
if method_id:
|
|
868
|
-
wh_parts.append("caller.id = $method_id")
|
|
869
|
-
params["method_id"] = method_id
|
|
870
|
-
if reason:
|
|
871
|
-
wh_parts.append("u.reason = $reason")
|
|
872
|
-
params["reason"] = reason
|
|
873
|
-
if microservice:
|
|
874
|
-
wh_parts.append("caller.microservice = $microservice")
|
|
875
|
-
params["microservice"] = microservice
|
|
876
|
-
if callee_simple:
|
|
877
|
-
wh_parts.append("u.callee_simple = $callee_simple")
|
|
878
|
-
params["callee_simple"] = callee_simple
|
|
879
|
-
where = ("WHERE " + " AND ".join(wh_parts)) if wh_parts else ""
|
|
880
|
-
return self._rows(
|
|
881
|
-
"MATCH (caller:Symbol)-[:UNRESOLVED_AT]->(u:UnresolvedCallSite) "
|
|
882
|
-
f"{where} "
|
|
883
|
-
"RETURN u.id AS id, caller.id AS caller_id, caller.fqn AS caller_fqn, "
|
|
884
|
-
"caller.microservice AS microservice, u.call_site_line AS call_site_line, "
|
|
885
|
-
"u.call_site_byte AS call_site_byte, u.arg_count AS arg_count, "
|
|
886
|
-
"u.callee_simple AS callee_simple, u.receiver_expr AS receiver_expr, "
|
|
887
|
-
"u.reason AS reason "
|
|
888
|
-
"ORDER BY u.call_site_line, u.call_site_byte "
|
|
889
|
-
"LIMIT $lim",
|
|
890
|
-
params,
|
|
891
|
-
)
|
|
892
|
-
|
|
893
|
-
def stats_unresolved_call_sites(
|
|
894
|
-
self,
|
|
895
|
-
*,
|
|
896
|
-
by: Literal["reason", "microservice", "caller_role"],
|
|
897
|
-
) -> list[dict[str, Any]]:
|
|
898
|
-
if by == "reason":
|
|
899
|
-
return self._rows(
|
|
900
|
-
"MATCH (:Symbol)-[:UNRESOLVED_AT]->(u:UnresolvedCallSite) "
|
|
901
|
-
"RETURN u.reason AS bucket, count(*) AS n ORDER BY n DESC",
|
|
902
|
-
)
|
|
903
|
-
if by == "microservice":
|
|
904
|
-
return self._rows(
|
|
905
|
-
"MATCH (caller:Symbol)-[:UNRESOLVED_AT]->(:UnresolvedCallSite) "
|
|
906
|
-
"RETURN caller.microservice AS bucket, count(*) AS n ORDER BY n DESC",
|
|
907
|
-
)
|
|
908
|
-
return self._rows(
|
|
909
|
-
"MATCH (caller:Symbol)-[:UNRESOLVED_AT]->(:UnresolvedCallSite) "
|
|
910
|
-
"MATCH (parent:Symbol)-[:DECLARES]->(caller) "
|
|
911
|
-
"RETURN parent.role AS bucket, count(*) AS n ORDER BY n DESC",
|
|
912
|
-
)
|
|
913
|
-
|
|
914
|
-
def _edge_row_count_from_method_ids(self, method_ids: list[str], rel: str) -> int:
|
|
915
|
-
"""Count outgoing ``rel`` edges from method symbols (describe rollup helper)."""
|
|
916
|
-
total = 0
|
|
917
|
-
for mid in method_ids:
|
|
918
|
-
rows = self._rows(
|
|
919
|
-
f"MATCH (x:Symbol {{id: $mid}})-[e:{rel}]->() RETURN count(e) AS n",
|
|
920
|
-
{"mid": mid},
|
|
921
|
-
)
|
|
922
|
-
total += int(rows[0].get("n") or 0) if rows else 0
|
|
923
|
-
return total
|
|
924
|
-
|
|
925
|
-
def _override_impl_ids_from_stored(self, method_id: str) -> list[str]:
|
|
926
|
-
"""Overrider method ids for a declaration method (stored ``[:OVERRIDES]`` in-hop)."""
|
|
927
|
-
rows = self._rows(
|
|
928
|
-
"MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol) "
|
|
929
|
-
"RETURN collect(DISTINCT mover.id) AS ids",
|
|
930
|
-
{"id": method_id},
|
|
931
|
-
)
|
|
932
|
-
return list(dict.fromkeys(_coerce_id_list(rows[0].get("ids") if rows else None)))
|
|
933
|
-
|
|
934
|
-
def _override_decl_ids_from_stored(self, method_id: str) -> list[str]:
|
|
935
|
-
"""Declaration method ids overridden by a concrete method (stored ``[:OVERRIDES]`` out-hop)."""
|
|
936
|
-
rows = self._rows(
|
|
937
|
-
"MATCH (m:Symbol {id: $id})-[:OVERRIDES]->(decl:Symbol) "
|
|
938
|
-
"RETURN collect(DISTINCT decl.id) AS ids",
|
|
939
|
-
{"id": method_id},
|
|
940
|
-
)
|
|
941
|
-
return list(dict.fromkeys(_coerce_id_list(rows[0].get("ids") if rows else None)))
|
|
942
|
-
|
|
943
|
-
def override_axis_rollup_for(self, method_id: str) -> dict[str, dict[str, int]]:
|
|
944
|
-
"""Dispatch-axis composed keys for method Symbols (describe-time only).
|
|
945
|
-
|
|
946
|
-
Dispatch hop uses materialized ``[:OVERRIDES]`` (same as ``override_axis_traversal_for`` /
|
|
947
|
-
``neighbors`` dot-keys). Terminal composed counts sum outgoing edges from overrider
|
|
948
|
-
methods. Omits keys with zero counts. Returns ``{}`` for non-methods, constructors,
|
|
949
|
-
and static methods.
|
|
950
|
-
"""
|
|
951
|
-
params = {"id": method_id}
|
|
952
|
-
gate = self._rows(
|
|
953
|
-
"MATCH (m:Symbol {id: $id}) "
|
|
954
|
-
"WHERE m.kind = 'method' "
|
|
955
|
-
"AND NOT list_contains(COALESCE(m.modifiers, []), 'static') "
|
|
956
|
-
"RETURN 1 AS ok LIMIT 1",
|
|
957
|
-
params,
|
|
958
|
-
)
|
|
959
|
-
if not gate:
|
|
960
|
-
return {}
|
|
961
|
-
|
|
962
|
-
rollup: dict[str, dict[str, int]] = {}
|
|
963
|
-
|
|
964
|
-
impl_ids = self._override_impl_ids_from_stored(method_id)
|
|
965
|
-
if impl_ids:
|
|
966
|
-
rollup["OVERRIDDEN_BY"] = {"in": 0, "out": len(impl_ids)}
|
|
967
|
-
n_dc = self._edge_row_count_from_method_ids(impl_ids, "DECLARES_CLIENT")
|
|
968
|
-
if n_dc > 0:
|
|
969
|
-
rollup["OVERRIDDEN_BY.DECLARES_CLIENT"] = {"in": 0, "out": n_dc}
|
|
970
|
-
n_dp = self._edge_row_count_from_method_ids(impl_ids, "DECLARES_PRODUCER")
|
|
971
|
-
if n_dp > 0:
|
|
972
|
-
rollup["OVERRIDDEN_BY.DECLARES_PRODUCER"] = {"in": 0, "out": n_dp}
|
|
973
|
-
n_ex = self._edge_row_count_from_method_ids(impl_ids, "EXPOSES")
|
|
974
|
-
if n_ex > 0:
|
|
975
|
-
rollup["OVERRIDDEN_BY.EXPOSES"] = {"in": 0, "out": n_ex}
|
|
976
|
-
|
|
977
|
-
decl_ids = self._override_decl_ids_from_stored(method_id)
|
|
978
|
-
if decl_ids:
|
|
979
|
-
rollup["OVERRIDES"] = {"in": 0, "out": len(decl_ids)}
|
|
980
|
-
|
|
981
|
-
return rollup
|
|
982
|
-
|
|
983
|
-
def _scope_counts(self, column: str) -> dict[str, int]:
|
|
984
|
-
"""Generic helper: count resolved type symbols grouped by `column`.
|
|
985
|
-
|
|
986
|
-
Empty-string keys mean the builder could not infer a value
|
|
987
|
-
(no build-marker ancestor / no path segment under project_root).
|
|
988
|
-
"""
|
|
989
|
-
try:
|
|
990
|
-
rows = self._rows(
|
|
991
|
-
f"MATCH (s:Symbol) WHERE s.resolved "
|
|
992
|
-
f"AND s.kind IN ['class','interface','enum','record','annotation'] "
|
|
993
|
-
f"RETURN s.{column} AS bucket, count(*) AS n"
|
|
994
|
-
)
|
|
995
|
-
except Exception:
|
|
996
|
-
return {}
|
|
997
|
-
out: dict[str, int] = {}
|
|
998
|
-
for r in rows:
|
|
999
|
-
key = r.get("bucket") or ""
|
|
1000
|
-
out[str(key)] = int(r.get("n") or 0)
|
|
1001
|
-
return out
|
|
1002
|
-
|
|
1003
|
-
def module_counts(self) -> dict[str, int]:
|
|
1004
|
-
"""Map of module name -> resolved type-symbol count."""
|
|
1005
|
-
return self._scope_counts("module")
|
|
1006
|
-
|
|
1007
|
-
def microservice_counts(self) -> dict[str, int]:
|
|
1008
|
-
"""Map of microservice name -> resolved type-symbol count."""
|
|
1009
|
-
return self._scope_counts("microservice")
|
|
1010
|
-
|
|
1011
|
-
# ---- symbol-level lookups ----
|
|
1012
|
-
|
|
1013
|
-
def find_by_name_or_fqn(self, name_or_fqn: str, *, kinds: list[str] | None = None,
|
|
1014
|
-
module: str | None = None,
|
|
1015
|
-
microservice: str | None = None,
|
|
1016
|
-
limit: int = 50,
|
|
1017
|
-
mode: str = "exact") -> list[SymbolHit]:
|
|
1018
|
-
# ``mode`` selects the name/FQN predicate. ``exact`` (default) preserves the
|
|
1019
|
-
# original ``s.name = $needle OR s.fqn = $needle``. ``prefix`` / ``contains``
|
|
1020
|
-
# use STARTS WITH / CONTAINS (Ladybug Cypher supports both — see
|
|
1021
|
-
# resolve_service.py); they back the ``find --fuzzy`` fallback (issue #375).
|
|
1022
|
-
# Fuzzy modes additionally exclude file/package Symbol nodes: their fqn is a
|
|
1023
|
-
# filesystem path, so a substring/prefix would leak filename rows (mirrors
|
|
1024
|
-
# the find_v2 fix, #411). Exact mode is unchanged for back-compat.
|
|
1025
|
-
# Empty needle: STARTS WITH '' / CONTAINS '' match every string, so a
|
|
1026
|
-
# fuzzy mode would silently return up to `limit` arbitrary Symbols. The
|
|
1027
|
-
# CLI guards this (query mode requires a positional), but keep the
|
|
1028
|
-
# backend safe-by-construction for any future caller.
|
|
1029
|
-
if mode != "exact" and not name_or_fqn:
|
|
1030
|
-
return []
|
|
1031
|
-
if mode == "exact":
|
|
1032
|
-
filters = ["(s.name = $needle OR s.fqn = $needle)"]
|
|
1033
|
-
elif mode == "prefix":
|
|
1034
|
-
filters = [
|
|
1035
|
-
"(s.name STARTS WITH $needle OR s.fqn STARTS WITH $needle)",
|
|
1036
|
-
"(s.kind <> 'file' AND s.kind <> 'package')",
|
|
1037
|
-
]
|
|
1038
|
-
elif mode == "contains":
|
|
1039
|
-
filters = [
|
|
1040
|
-
"(s.name CONTAINS $needle OR s.fqn CONTAINS $needle)",
|
|
1041
|
-
"(s.kind <> 'file' AND s.kind <> 'package')",
|
|
1042
|
-
]
|
|
1043
|
-
else:
|
|
1044
|
-
raise ValueError(f"unknown find_by_name_or_fqn mode: {mode!r}")
|
|
1045
|
-
params: dict[str, Any] = {"needle": name_or_fqn}
|
|
1046
|
-
if kinds:
|
|
1047
|
-
params["kinds"] = kinds
|
|
1048
|
-
filters.append("s.kind IN $kinds")
|
|
1049
|
-
filters.extend(_scope_filters("s", module=module, microservice=microservice, params=params))
|
|
1050
|
-
where = " AND ".join(filters)
|
|
1051
|
-
q = f"MATCH (s:Symbol) WHERE {where} RETURN {_SYMBOL_RETURN} LIMIT {int(limit)}"
|
|
1052
|
-
return [_row_to_symbol(r) for r in self._rows(q, params)]
|
|
1053
|
-
|
|
1054
|
-
def list_by_role(self, role: str, *, module: str | None = None,
|
|
1055
|
-
microservice: str | None = None,
|
|
1056
|
-
capability: str | None = None,
|
|
1057
|
-
limit: int = 100) -> list[SymbolHit]:
|
|
1058
|
-
filters = ["s.role = $role"]
|
|
1059
|
-
params: dict[str, Any] = {"role": role}
|
|
1060
|
-
if capability:
|
|
1061
|
-
filters.append("$capability IN s.capabilities")
|
|
1062
|
-
params["capability"] = capability
|
|
1063
|
-
filters.extend(_scope_filters("s", module=module, microservice=microservice, params=params))
|
|
1064
|
-
where = " AND ".join(filters)
|
|
1065
|
-
q = f"MATCH (s:Symbol) WHERE {where} RETURN {_SYMBOL_RETURN} LIMIT {int(limit)}"
|
|
1066
|
-
return [_row_to_symbol(r) for r in self._rows(q, params)]
|
|
1067
|
-
|
|
1068
|
-
def list_by_annotation(self, annotation: str, *, module: str | None = None,
|
|
1069
|
-
microservice: str | None = None,
|
|
1070
|
-
capability: str | None = None,
|
|
1071
|
-
limit: int = 100) -> list[SymbolHit]:
|
|
1072
|
-
# Ladybug supports `list_contains` for STRING[].
|
|
1073
|
-
filters = ["list_contains(s.annotations, $ann)"]
|
|
1074
|
-
params: dict[str, Any] = {"ann": annotation}
|
|
1075
|
-
if capability:
|
|
1076
|
-
filters.append("$capability IN s.capabilities")
|
|
1077
|
-
params["capability"] = capability
|
|
1078
|
-
filters.extend(_scope_filters("s", module=module, microservice=microservice, params=params))
|
|
1079
|
-
where = " AND ".join(filters)
|
|
1080
|
-
q = f"MATCH (s:Symbol) WHERE {where} RETURN {_SYMBOL_RETURN} LIMIT {int(limit)}"
|
|
1081
|
-
return [_row_to_symbol(r) for r in self._rows(q, params)]
|
|
1082
|
-
|
|
1083
|
-
def list_by_capability(self, capability: str, *, module: str | None = None,
|
|
1084
|
-
microservice: str | None = None,
|
|
1085
|
-
limit: int = 100) -> list[SymbolHit]:
|
|
1086
|
-
filters = ["$capability IN s.capabilities"]
|
|
1087
|
-
params: dict[str, Any] = {"capability": capability}
|
|
1088
|
-
filters.extend(_scope_filters("s", module=module, microservice=microservice, params=params))
|
|
1089
|
-
where = " AND ".join(filters)
|
|
1090
|
-
q = f"MATCH (s:Symbol) WHERE {where} RETURN {_SYMBOL_RETURN} LIMIT {int(limit)}"
|
|
1091
|
-
return [_row_to_symbol(r) for r in self._rows(q, params)]
|
|
1092
|
-
|
|
1093
|
-
# ---- edge traversals ----
|
|
1094
|
-
|
|
1095
|
-
def find_implementors(self, interface_name_or_fqn: str, *,
|
|
1096
|
-
module: str | None = None,
|
|
1097
|
-
microservice: str | None = None,
|
|
1098
|
-
capability: str | None = None,
|
|
1099
|
-
limit: int = 100) -> list[SymbolHit]:
|
|
1100
|
-
filters = ["(i.name = $needle OR i.fqn = $needle)"]
|
|
1101
|
-
params: dict[str, Any] = {"needle": interface_name_or_fqn}
|
|
1102
|
-
if capability:
|
|
1103
|
-
filters.append("$capability IN c.capabilities")
|
|
1104
|
-
params["capability"] = capability
|
|
1105
|
-
filters.extend(_scope_filters("c", module=module, microservice=microservice, params=params))
|
|
1106
|
-
where = " AND ".join(filters)
|
|
1107
|
-
q = (
|
|
1108
|
-
f"MATCH (c:Symbol)-[:IMPLEMENTS]->(i:Symbol) WHERE {where} "
|
|
1109
|
-
f"RETURN DISTINCT {_symbol_return_for('c')} "
|
|
1110
|
-
f"LIMIT {int(limit)}"
|
|
1111
|
-
)
|
|
1112
|
-
return [_row_to_symbol(r) for r in self._rows(q, params)]
|
|
1113
|
-
|
|
1114
|
-
def find_subclasses(self, class_name_or_fqn: str, *,
|
|
1115
|
-
module: str | None = None,
|
|
1116
|
-
microservice: str | None = None,
|
|
1117
|
-
capability: str | None = None,
|
|
1118
|
-
limit: int = 100) -> list[SymbolHit]:
|
|
1119
|
-
filters = ["(b.name = $needle OR b.fqn = $needle)"]
|
|
1120
|
-
params: dict[str, Any] = {"needle": class_name_or_fqn}
|
|
1121
|
-
if capability:
|
|
1122
|
-
filters.append("$capability IN s.capabilities")
|
|
1123
|
-
params["capability"] = capability
|
|
1124
|
-
filters.extend(_scope_filters("s", module=module, microservice=microservice, params=params))
|
|
1125
|
-
where = " AND ".join(filters)
|
|
1126
|
-
q = (
|
|
1127
|
-
f"MATCH (s:Symbol)-[:EXTENDS]->(b:Symbol) WHERE {where} "
|
|
1128
|
-
f"RETURN DISTINCT {_SYMBOL_RETURN} "
|
|
1129
|
-
f"LIMIT {int(limit)}"
|
|
1130
|
-
)
|
|
1131
|
-
return [_row_to_symbol(r) for r in self._rows(q, params)]
|
|
1132
|
-
|
|
1133
|
-
def find_injectors(self, target_name_or_fqn: str, *,
|
|
1134
|
-
module: str | None = None,
|
|
1135
|
-
microservice: str | None = None,
|
|
1136
|
-
capability: str | None = None,
|
|
1137
|
-
limit: int = 100) -> list[EdgeHit]:
|
|
1138
|
-
filters = ["(t.name = $needle OR t.fqn = $needle)"]
|
|
1139
|
-
params: dict[str, Any] = {"needle": target_name_or_fqn}
|
|
1140
|
-
if capability:
|
|
1141
|
-
# Filter on the consumer (src) side: "which injectors carry this capability?"
|
|
1142
|
-
filters.append("$capability IN s.capabilities")
|
|
1143
|
-
params["capability"] = capability
|
|
1144
|
-
filters.extend(_scope_filters("s", module=module, microservice=microservice, params=params))
|
|
1145
|
-
where = " AND ".join(filters)
|
|
1146
|
-
# Project both sides of the edge with prefixed aliases (`s_*` / `t_*`)
|
|
1147
|
-
# so we can split rows back into source / target SymbolHits without
|
|
1148
|
-
# column-name collisions.
|
|
1149
|
-
s_proj = ", ".join(
|
|
1150
|
-
f"s.{c} AS s_{c}" for c in (
|
|
1151
|
-
"id", "kind", "name", "fqn", "package", "module", "microservice",
|
|
1152
|
-
"filename", "start_line", "end_line", "start_byte", "end_byte",
|
|
1153
|
-
"modifiers", "annotations", "capabilities", "role", "signature", "parent_id", "resolved", "generated", "generated_by",
|
|
1154
|
-
)
|
|
1155
|
-
)
|
|
1156
|
-
t_proj = ", ".join(
|
|
1157
|
-
f"t.{c} AS t_{c}" for c in (
|
|
1158
|
-
"id", "kind", "name", "fqn", "package", "module", "microservice",
|
|
1159
|
-
"filename", "start_line", "end_line", "start_byte", "end_byte",
|
|
1160
|
-
"modifiers", "annotations", "capabilities", "role", "signature", "parent_id", "resolved", "generated", "generated_by",
|
|
1161
|
-
)
|
|
1162
|
-
)
|
|
1163
|
-
q = (
|
|
1164
|
-
f"MATCH (s:Symbol)-[e:INJECTS]->(t:Symbol) WHERE {where} "
|
|
1165
|
-
f"RETURN {s_proj}, {t_proj}, "
|
|
1166
|
-
f"e.mechanism AS mechanism, e.annotation AS annotation, "
|
|
1167
|
-
f"e.field_or_param AS field_or_param, e.resolved AS resolved "
|
|
1168
|
-
f"LIMIT {int(limit)}"
|
|
1169
|
-
)
|
|
1170
|
-
out: list[EdgeHit] = []
|
|
1171
|
-
for r in self._rows(q, params):
|
|
1172
|
-
src = _row_to_symbol({k[2:]: v for k, v in r.items() if k.startswith("s_")})
|
|
1173
|
-
dst = _row_to_symbol({k[2:]: v for k, v in r.items() if k.startswith("t_")})
|
|
1174
|
-
out.append(EdgeHit(
|
|
1175
|
-
type="INJECTS", src=src, dst=dst,
|
|
1176
|
-
mechanism=r.get("mechanism") or "",
|
|
1177
|
-
annotation=r.get("annotation") or "",
|
|
1178
|
-
field_or_param=r.get("field_or_param") or "",
|
|
1179
|
-
resolved=bool(r.get("resolved", True)),
|
|
1180
|
-
))
|
|
1181
|
-
return out
|
|
1182
|
-
|
|
1183
|
-
def _method_ids_for_call_graph_needle(self, needle: str, *, limit: int) -> list[str]:
|
|
1184
|
-
rows = self._rows(
|
|
1185
|
-
"MATCH (s:Symbol) WHERE s.fqn = $n RETURN s.id AS id, s.kind AS kind LIMIT 1",
|
|
1186
|
-
{"n": needle},
|
|
1187
|
-
)
|
|
1188
|
-
if not rows:
|
|
1189
|
-
alt = _call_graph_needle_phantom_arity_alt(needle)
|
|
1190
|
-
if alt:
|
|
1191
|
-
rows = self._rows(
|
|
1192
|
-
"MATCH (s:Symbol) WHERE s.fqn = $n RETURN s.id AS id, s.kind AS kind LIMIT 1",
|
|
1193
|
-
{"n": alt},
|
|
1194
|
-
)
|
|
1195
|
-
if rows:
|
|
1196
|
-
kind = str(rows[0].get("kind") or "")
|
|
1197
|
-
sid = str(rows[0].get("id") or "")
|
|
1198
|
-
if kind in ("class", "interface", "enum", "record", "annotation") and sid:
|
|
1199
|
-
mrows = self._rows(
|
|
1200
|
-
"MATCH (t:Symbol {id: $tid})-[:DECLARES]->(m:Symbol) RETURN m.id AS id "
|
|
1201
|
-
f"LIMIT {int(limit)}",
|
|
1202
|
-
{"tid": sid},
|
|
1203
|
-
)
|
|
1204
|
-
return [str(r["id"]) for r in mrows if r.get("id")]
|
|
1205
|
-
if kind in ("method", "constructor") and sid:
|
|
1206
|
-
return [sid]
|
|
1207
|
-
rows2 = self._rows(
|
|
1208
|
-
f"MATCH (s:Symbol) WHERE s.name = $n AND s.kind IN ['method','constructor'] "
|
|
1209
|
-
f"RETURN s.id AS id LIMIT {int(limit)}",
|
|
1210
|
-
{"n": needle},
|
|
1211
|
-
)
|
|
1212
|
-
return [str(r["id"]) for r in rows2 if r.get("id")]
|
|
1213
|
-
|
|
1214
|
-
def _walk_calls(
|
|
1215
|
-
self,
|
|
1216
|
-
needle: str,
|
|
1217
|
-
*,
|
|
1218
|
-
side: str,
|
|
1219
|
-
depth: int = 1,
|
|
1220
|
-
limit: int = 100,
|
|
1221
|
-
min_confidence: float = 0.0,
|
|
1222
|
-
exclude_external: bool = True,
|
|
1223
|
-
module: str | None = None,
|
|
1224
|
-
microservice: str | None = None,
|
|
1225
|
-
) -> list[CallEdge]:
|
|
1226
|
-
"""BFS the CALLS graph outward from ``needle`` along one relationship end.
|
|
1227
|
-
|
|
1228
|
-
``side="callers"`` treats the needle as the callee: the frontier matches
|
|
1229
|
-
the ``callee`` end and discovered/expanded/external-filtered nodes are the
|
|
1230
|
-
``caller`` (src) end. ``side="callees"`` is the mirror. The two public
|
|
1231
|
-
methods differ only in that orientation, so the BFS body is shared here.
|
|
1232
|
-
"""
|
|
1233
|
-
if side == "callers":
|
|
1234
|
-
scope_alias, frontier_end, discovered = "caller", "callee", "src"
|
|
1235
|
-
else:
|
|
1236
|
-
scope_alias, frontier_end, discovered = "callee", "caller", "dst"
|
|
1237
|
-
frontier = self._method_ids_for_call_graph_needle(needle, limit=max(limit, 50))
|
|
1238
|
-
if not frontier:
|
|
1239
|
-
return []
|
|
1240
|
-
caller_proj = ", ".join(f"caller.{c} AS caller_{c}" for c in _SYM_COLS)
|
|
1241
|
-
callee_proj = ", ".join(f"callee.{c} AS callee_{c}" for c in _SYM_COLS)
|
|
1242
|
-
out: list[CallEdge] = []
|
|
1243
|
-
seen: set[tuple[str, str, int, int]] = set()
|
|
1244
|
-
for _ in range(max(1, int(depth))):
|
|
1245
|
-
params: dict[str, Any] = {
|
|
1246
|
-
"frontier": list(frontier),
|
|
1247
|
-
"minc": float(min_confidence),
|
|
1248
|
-
}
|
|
1249
|
-
sc = _scope_filters(scope_alias, module=module, microservice=microservice, params=params)
|
|
1250
|
-
wh_parts = [f"{frontier_end}.id IN $frontier", "c.confidence >= $minc"]
|
|
1251
|
-
wh_parts.extend(sc)
|
|
1252
|
-
wh = " AND ".join(wh_parts)
|
|
1253
|
-
q = (
|
|
1254
|
-
f"MATCH (caller:Symbol)-[c:CALLS]->(callee:Symbol) WHERE {wh} "
|
|
1255
|
-
f"RETURN {caller_proj}, {callee_proj}, "
|
|
1256
|
-
f"c.call_site_line AS call_site_line, c.call_site_byte AS call_site_byte, "
|
|
1257
|
-
f"c.arg_count AS arg_count, c.confidence AS confidence, c.strategy AS strategy, "
|
|
1258
|
-
f"c.source AS source, c.resolved AS resolved "
|
|
1259
|
-
f"LIMIT {int(limit) * 8}"
|
|
1260
|
-
)
|
|
1261
|
-
next_frontier: list[str] = []
|
|
1262
|
-
for row in self._rows(q, params):
|
|
1263
|
-
ce = _row_to_call_edge(row)
|
|
1264
|
-
# The needle itself may be external (e.g. java.util.List#add);
|
|
1265
|
-
# filter only the discovered end so internal callers/callees
|
|
1266
|
-
# that touch it are still surfaced.
|
|
1267
|
-
disc_fqn = ce.src.fqn if discovered == "src" else ce.dst.fqn
|
|
1268
|
-
disc_id = ce.src.id if discovered == "src" else ce.dst.id
|
|
1269
|
-
if exclude_external and _is_external_fqn(disc_fqn):
|
|
1270
|
-
continue
|
|
1271
|
-
key = (ce.src.id, ce.dst.id, ce.call_site_line, ce.call_site_byte)
|
|
1272
|
-
if key in seen:
|
|
1273
|
-
continue
|
|
1274
|
-
seen.add(key)
|
|
1275
|
-
out.append(ce)
|
|
1276
|
-
next_frontier.append(disc_id)
|
|
1277
|
-
if len(out) >= limit:
|
|
1278
|
-
return out
|
|
1279
|
-
frontier = list(dict.fromkeys(next_frontier))
|
|
1280
|
-
if not frontier:
|
|
1281
|
-
break
|
|
1282
|
-
return out
|
|
1283
|
-
|
|
1284
|
-
def find_callers(
|
|
1285
|
-
self, needle: str, *,
|
|
1286
|
-
depth: int = 1,
|
|
1287
|
-
limit: int = 100,
|
|
1288
|
-
min_confidence: float = 0.0,
|
|
1289
|
-
exclude_external: bool = True,
|
|
1290
|
-
module: str | None = None,
|
|
1291
|
-
microservice: str | None = None,
|
|
1292
|
-
) -> list[CallEdge]:
|
|
1293
|
-
return self._walk_calls(
|
|
1294
|
-
needle, side="callers", depth=depth, limit=limit,
|
|
1295
|
-
min_confidence=min_confidence, exclude_external=exclude_external,
|
|
1296
|
-
module=module, microservice=microservice,
|
|
1297
|
-
)
|
|
1298
|
-
|
|
1299
|
-
def find_callees(
|
|
1300
|
-
self, needle: str, *,
|
|
1301
|
-
depth: int = 1,
|
|
1302
|
-
limit: int = 100,
|
|
1303
|
-
min_confidence: float = 0.0,
|
|
1304
|
-
exclude_external: bool = True,
|
|
1305
|
-
module: str | None = None,
|
|
1306
|
-
microservice: str | None = None,
|
|
1307
|
-
) -> list[CallEdge]:
|
|
1308
|
-
return self._walk_calls(
|
|
1309
|
-
needle, side="callees", depth=depth, limit=limit,
|
|
1310
|
-
min_confidence=min_confidence, exclude_external=exclude_external,
|
|
1311
|
-
module=module, microservice=microservice,
|
|
1312
|
-
)
|
|
1313
|
-
|
|
1314
|
-
def expand_methods(
|
|
1315
|
-
self, fqns: list[str], *, depth: int = 1,
|
|
1316
|
-
min_confidence: float = 0.0, limit: int = 200,
|
|
1317
|
-
exclude_external: bool = True,
|
|
1318
|
-
) -> list[tuple[str, float]]:
|
|
1319
|
-
"""Reach type FQNs from seed types via DECLARES → CALLS → DECLARES (reverse).
|
|
1320
|
-
|
|
1321
|
-
Each entry is ``(type_fqn, path_confidence)``. ``path_confidence`` is the
|
|
1322
|
-
maximum, over call paths from seed methods, of the minimum ``CALLS.confidence``
|
|
1323
|
-
along that path (seed methods anchor at ``1.0`` before the first hop).
|
|
1324
|
-
|
|
1325
|
-
When ``exclude_external`` is true (default), types whose FQN matches the
|
|
1326
|
-
same JDK/Spring/Lombok prefixes as ``find_callees`` are omitted from the
|
|
1327
|
-
returned list (they are not indexed in LanceDB anyway). BFS still walks
|
|
1328
|
-
through external callees to find further project types.
|
|
1329
|
-
"""
|
|
1330
|
-
if not fqns or depth < 1:
|
|
1331
|
-
return []
|
|
1332
|
-
seed_mids: list[str] = []
|
|
1333
|
-
for tfqn in fqns:
|
|
1334
|
-
r = self._rows(
|
|
1335
|
-
"MATCH (t:Symbol) WHERE t.fqn = $f AND t.kind IN ['class','interface','enum','record','annotation'] "
|
|
1336
|
-
"RETURN t.id AS id LIMIT 1",
|
|
1337
|
-
{"f": tfqn},
|
|
1338
|
-
)
|
|
1339
|
-
if not r or not r[0].get("id"):
|
|
1340
|
-
continue
|
|
1341
|
-
tid = str(r[0]["id"])
|
|
1342
|
-
mrows = self._rows(
|
|
1343
|
-
"MATCH (t:Symbol {id: $tid})-[:DECLARES]->(m:Symbol) RETURN m.id AS id",
|
|
1344
|
-
{"tid": tid},
|
|
1345
|
-
)
|
|
1346
|
-
seed_mids.extend(str(x["id"]) for x in mrows if x.get("id"))
|
|
1347
|
-
seed_mids = list(dict.fromkeys(seed_mids))
|
|
1348
|
-
if not seed_mids:
|
|
1349
|
-
return []
|
|
1350
|
-
frontier_conf: dict[str, float] = {mid: 1.0 for mid in seed_mids}
|
|
1351
|
-
type_best: dict[str, float] = {}
|
|
1352
|
-
ordered_types: list[str] = []
|
|
1353
|
-
seen_order: set[str] = set()
|
|
1354
|
-
for _ in range(int(depth)):
|
|
1355
|
-
if not frontier_conf:
|
|
1356
|
-
break
|
|
1357
|
-
ids = list(frontier_conf.keys())
|
|
1358
|
-
rows = self._rows(
|
|
1359
|
-
"MATCH (m:Symbol)-[c:CALLS]->(n:Symbol) WHERE m.id IN $ids AND c.confidence >= $mc "
|
|
1360
|
-
"RETURN m.id AS mid, n.id AS nid, c.confidence AS conf",
|
|
1361
|
-
{"ids": ids, "mc": float(min_confidence)},
|
|
1362
|
-
)
|
|
1363
|
-
next_conf: dict[str, float] = {}
|
|
1364
|
-
for r in rows:
|
|
1365
|
-
mid = str(r.get("mid") or "")
|
|
1366
|
-
nid = str(r.get("nid") or "")
|
|
1367
|
-
if not mid or not nid:
|
|
1368
|
-
continue
|
|
1369
|
-
raw_conf = r.get("conf")
|
|
1370
|
-
try:
|
|
1371
|
-
ec = float(raw_conf) if raw_conf is not None else 0.0
|
|
1372
|
-
except (TypeError, ValueError):
|
|
1373
|
-
ec = 0.0
|
|
1374
|
-
parent = frontier_conf.get(mid)
|
|
1375
|
-
if parent is None:
|
|
1376
|
-
continue
|
|
1377
|
-
new_c = min(parent, ec)
|
|
1378
|
-
next_conf[nid] = max(next_conf.get(nid, 0.0), new_c)
|
|
1379
|
-
|
|
1380
|
-
if not next_conf:
|
|
1381
|
-
break
|
|
1382
|
-
|
|
1383
|
-
for nid, path_c in next_conf.items():
|
|
1384
|
-
srows = self._rows(
|
|
1385
|
-
"MATCH (s:Symbol {id: $id}) RETURN s.fqn AS fqn LIMIT 1",
|
|
1386
|
-
{"id": nid},
|
|
1387
|
-
)
|
|
1388
|
-
if not srows:
|
|
1389
|
-
continue
|
|
1390
|
-
mfqn = str(srows[0].get("fqn") or "")
|
|
1391
|
-
if "#" not in mfqn:
|
|
1392
|
-
continue
|
|
1393
|
-
tpart = mfqn.split("#", 1)[0]
|
|
1394
|
-
if not tpart:
|
|
1395
|
-
continue
|
|
1396
|
-
is_ext = _is_external_fqn(tpart)
|
|
1397
|
-
if exclude_external and is_ext:
|
|
1398
|
-
pass
|
|
1399
|
-
else:
|
|
1400
|
-
type_best[tpart] = max(type_best.get(tpart, 0.0), path_c)
|
|
1401
|
-
if tpart not in seen_order:
|
|
1402
|
-
seen_order.add(tpart)
|
|
1403
|
-
ordered_types.append(tpart)
|
|
1404
|
-
if len(ordered_types) >= limit:
|
|
1405
|
-
return [(t, type_best[t]) for t in ordered_types[:limit]]
|
|
1406
|
-
|
|
1407
|
-
frontier_conf = next_conf
|
|
1408
|
-
|
|
1409
|
-
return [(t, type_best[t]) for t in ordered_types[:limit]]
|
|
1410
|
-
|
|
1411
|
-
def neighbors(self, fqn_or_name: str, *, depth: int = 1,
|
|
1412
|
-
edge_types: list[str] | None = None,
|
|
1413
|
-
direction: str = "both", limit: int = 200) -> list[SymbolHit]:
|
|
1414
|
-
"""BFS over `edge_types` up to `depth` hops. `direction` in {out, in, both}."""
|
|
1415
|
-
if depth < 1:
|
|
1416
|
-
return []
|
|
1417
|
-
edges = edge_types or ["EXTENDS", "IMPLEMENTS", "INJECTS", "DECLARES", "CALLS"]
|
|
1418
|
-
edge_pattern = "|".join(edges)
|
|
1419
|
-
if direction == "out":
|
|
1420
|
-
arrow_l, arrow_r = "-", "->"
|
|
1421
|
-
elif direction == "in":
|
|
1422
|
-
arrow_l, arrow_r = "<-", "-"
|
|
1423
|
-
else:
|
|
1424
|
-
arrow_l, arrow_r = "-", "-"
|
|
1425
|
-
q = (
|
|
1426
|
-
f"MATCH (root:Symbol) WHERE root.name = $needle OR root.fqn = $needle "
|
|
1427
|
-
f"MATCH path = (root){arrow_l}[:{edge_pattern}*1..{int(depth)}]{arrow_r}(n:Symbol) "
|
|
1428
|
-
f"RETURN DISTINCT {_symbol_return_for('n')} "
|
|
1429
|
-
f"LIMIT {int(limit)}"
|
|
1430
|
-
)
|
|
1431
|
-
return [_row_to_symbol(r) for r in self._rows(q, {"needle": fqn_or_name})]
|
|
1432
|
-
|
|
1433
|
-
def impact_analysis(self, fqn_or_name: str, *, depth: int = 2,
|
|
1434
|
-
limit: int = 300) -> list[SymbolHit]:
|
|
1435
|
-
"""Reverse closure over INJECTS + IMPLEMENTS (who breaks if `fqn` changes)."""
|
|
1436
|
-
q = (
|
|
1437
|
-
f"MATCH (target:Symbol) WHERE target.name = $needle OR target.fqn = $needle "
|
|
1438
|
-
f"MATCH (n:Symbol)-[:INJECTS|IMPLEMENTS|EXTENDS*1..{int(depth)}]->(target) "
|
|
1439
|
-
f"RETURN DISTINCT {_symbol_return_for('n')} "
|
|
1440
|
-
f"LIMIT {int(limit)}"
|
|
1441
|
-
)
|
|
1442
|
-
return [_row_to_symbol(r) for r in self._rows(q, {"needle": fqn_or_name})]
|
|
1443
|
-
|
|
1444
|
-
# ---- flow tracing (entrypoint -> service -> integration / repository) ----
|
|
1445
|
-
|
|
1446
|
-
# Default ordered waterfall of role stages. Each stage collects neighbors of
|
|
1447
|
-
# the previous stage whose role matches the allow-list. Phantom / unresolved
|
|
1448
|
-
# symbols are excluded so we don't propagate noise across the boundary.
|
|
1449
|
-
_FLOW_STAGES: tuple[tuple[str, ...], ...] = (
|
|
1450
|
-
("CONTROLLER",),
|
|
1451
|
-
("SERVICE", "COMPONENT"),
|
|
1452
|
-
("CLIENT", "REPOSITORY", "MAPPER"),
|
|
1453
|
-
)
|
|
1454
|
-
|
|
1455
|
-
# Stage-0 accepts any entrypoint-like role. COMPONENT is included because
|
|
1456
|
-
# Kafka listeners / @Scheduled orchestrators are frequently plain
|
|
1457
|
-
# @Component, not @Controller; SERVICE is included so we don't drop
|
|
1458
|
-
# orchestrator seeds when the caller already narrowed the vector search
|
|
1459
|
-
# to services.
|
|
1460
|
-
_ENTRYPOINT_ROLES: tuple[str, ...] = (
|
|
1461
|
-
"CONTROLLER", "COMPONENT", "SERVICE", "CLIENT",
|
|
1462
|
-
)
|
|
1463
|
-
|
|
1464
|
-
def trace_flow(self, seed_fqns: list[str], *,
|
|
1465
|
-
module: str | None = None,
|
|
1466
|
-
microservice: str | None = None,
|
|
1467
|
-
depth: int = 2, stage_limit: int = 20,
|
|
1468
|
-
follow_calls: bool = True,
|
|
1469
|
-
min_call_confidence: float = 0.0,
|
|
1470
|
-
exclude_external: bool = True) -> list[list[StageSymbol]]:
|
|
1471
|
-
"""Walk stages `CONTROLLER -> SERVICE/COMPONENT -> CLIENT/REPOSITORY/MAPPER`.
|
|
1472
|
-
|
|
1473
|
-
Returns a list of stages; each stage is a list of SymbolHit. The first
|
|
1474
|
-
stage is the seed set (entrypoints matched by FQN, filtered to
|
|
1475
|
-
orchestrator-like roles — see `_ENTRYPOINT_ROLES`). If role-filtered
|
|
1476
|
-
seeds come back empty we fall back to unfiltered seeds so a caller
|
|
1477
|
-
with no CONTROLLER coverage still gets *something* back.
|
|
1478
|
-
Each subsequent stage is the neighbor-set (INJECTS+EXTENDS+IMPLEMENTS,
|
|
1479
|
-
optionally merged with type-to-type paths through DECLARES+CALLS when
|
|
1480
|
-
`follow_calls` is true) of the previous stage, restricted to the
|
|
1481
|
-
stage's role allow-list.
|
|
1482
|
-
|
|
1483
|
-
Defaults: ``depth=2`` (clamped to 1..3), ``follow_calls=True``,
|
|
1484
|
-
``min_call_confidence=0.0``, ``exclude_external=True``. The latter only
|
|
1485
|
-
filters symbols reached via the DECLARES+CALLS hop: discovered **type**
|
|
1486
|
-
symbols matching external FQN prefixes (same list as ``expand_methods`` /
|
|
1487
|
-
the callee side of ``find_callees``), not the seed frontier. INJECTS /
|
|
1488
|
-
EXTENDS / IMPLEMENTS hops ignore ``exclude_external``.
|
|
1489
|
-
|
|
1490
|
-
``depth`` is the neighbour hop count per stage (not total trace depth).
|
|
1491
|
-
"""
|
|
1492
|
-
if not seed_fqns:
|
|
1493
|
-
return []
|
|
1494
|
-
depth = max(1, min(3, int(depth)))
|
|
1495
|
-
|
|
1496
|
-
stages: list[list[StageSymbol]] = []
|
|
1497
|
-
visited_fqns: set[str] = set()
|
|
1498
|
-
|
|
1499
|
-
def _run_seed_query(entry_roles: tuple[str, ...] | None) -> list[SymbolHit]:
|
|
1500
|
-
filters = ["s.fqn IN $fqns"]
|
|
1501
|
-
params: dict[str, Any] = {"fqns": list(seed_fqns)}
|
|
1502
|
-
filters.extend(_scope_filters(
|
|
1503
|
-
"s", module=module, microservice=microservice, params=params,
|
|
1504
|
-
))
|
|
1505
|
-
if entry_roles:
|
|
1506
|
-
params["entry_roles"] = list(entry_roles)
|
|
1507
|
-
# Ladybug 0.17.x does not support parameterized lists inside ANY
|
|
1508
|
-
# comprehensions, so we expand the fixed capability set as
|
|
1509
|
-
# individual list_contains predicates ORed together.
|
|
1510
|
-
cap_predicates = " OR ".join(
|
|
1511
|
-
f"list_contains(s.capabilities, '{c}')"
|
|
1512
|
-
for c in ("MESSAGE_LISTENER", "SCHEDULED_TASK")
|
|
1513
|
-
)
|
|
1514
|
-
filters.append(
|
|
1515
|
-
f"(s.role IN $entry_roles OR {cap_predicates})"
|
|
1516
|
-
)
|
|
1517
|
-
where = " AND ".join(filters)
|
|
1518
|
-
q0 = (
|
|
1519
|
-
f"MATCH (s:Symbol) WHERE {where} "
|
|
1520
|
-
f"RETURN {_SYMBOL_RETURN} LIMIT {int(stage_limit)}"
|
|
1521
|
-
)
|
|
1522
|
-
return [_row_to_symbol(r) for r in self._rows(q0, params)]
|
|
1523
|
-
|
|
1524
|
-
seed_rows = _run_seed_query(self._ENTRYPOINT_ROLES)
|
|
1525
|
-
if not seed_rows:
|
|
1526
|
-
seed_rows = _run_seed_query(None)
|
|
1527
|
-
if not seed_rows:
|
|
1528
|
-
return []
|
|
1529
|
-
stages.append([StageSymbol(symbol=r, via=[]) for r in seed_rows])
|
|
1530
|
-
for h in seed_rows:
|
|
1531
|
-
if h.fqn:
|
|
1532
|
-
visited_fqns.add(h.fqn)
|
|
1533
|
-
|
|
1534
|
-
frontier_fqns: list[str] = [h.fqn for h in seed_rows if h.fqn]
|
|
1535
|
-
for stage_roles in self._FLOW_STAGES[1:]:
|
|
1536
|
-
if not frontier_fqns:
|
|
1537
|
-
break
|
|
1538
|
-
|
|
1539
|
-
# Single-hop BFS repeated up to `depth` times. Each iteration
|
|
1540
|
-
# knows which edge type and parent node produced a newly-
|
|
1541
|
-
# discovered symbol, so we can label every stage entry.
|
|
1542
|
-
stage_results: dict[str, StageSymbol] = {}
|
|
1543
|
-
current_frontier = list(frontier_fqns)
|
|
1544
|
-
|
|
1545
|
-
for hop in range(1, depth + 1):
|
|
1546
|
-
if not current_frontier:
|
|
1547
|
-
break
|
|
1548
|
-
params: dict[str, Any] = {
|
|
1549
|
-
"fqns": current_frontier,
|
|
1550
|
-
"roles": list(stage_roles),
|
|
1551
|
-
}
|
|
1552
|
-
scope = _scope_filters(
|
|
1553
|
-
"n", module=module, microservice=microservice, params=params,
|
|
1554
|
-
)
|
|
1555
|
-
scope_clause = (" AND " + " AND ".join(scope)) if scope else ""
|
|
1556
|
-
q = (
|
|
1557
|
-
f"MATCH (root:Symbol)-[e:INJECTS|EXTENDS|IMPLEMENTS]-(n:Symbol) "
|
|
1558
|
-
f"WHERE root.fqn IN $fqns AND n.role IN $roles AND n.resolved{scope_clause} "
|
|
1559
|
-
f"RETURN {_symbol_return_for('n')}, "
|
|
1560
|
-
f"label(e) AS edge_type, root.fqn AS from_fqn "
|
|
1561
|
-
f"LIMIT {int(stage_limit) * 4}"
|
|
1562
|
-
)
|
|
1563
|
-
next_frontier: list[str] = []
|
|
1564
|
-
def _ingest_flow_row(
|
|
1565
|
-
row: dict[str, Any], *, filter_external_fqn: bool = False,
|
|
1566
|
-
) -> None:
|
|
1567
|
-
sym = _row_to_symbol(row)
|
|
1568
|
-
if (
|
|
1569
|
-
filter_external_fqn
|
|
1570
|
-
and exclude_external
|
|
1571
|
-
and _is_external_fqn(sym.fqn)
|
|
1572
|
-
):
|
|
1573
|
-
return
|
|
1574
|
-
if not sym.fqn or sym.fqn in visited_fqns:
|
|
1575
|
-
return
|
|
1576
|
-
edge = ViaEdge(
|
|
1577
|
-
edge_type=str(row.get("edge_type") or ""),
|
|
1578
|
-
from_fqn=str(row.get("from_fqn") or ""),
|
|
1579
|
-
hop=hop,
|
|
1580
|
-
caller_node_id=str(row.get("caller_client_id") or ""),
|
|
1581
|
-
)
|
|
1582
|
-
existing = stage_results.get(sym.fqn)
|
|
1583
|
-
if existing is None:
|
|
1584
|
-
stage_results[sym.fqn] = StageSymbol(symbol=sym, via=[edge])
|
|
1585
|
-
next_frontier.append(sym.fqn)
|
|
1586
|
-
else:
|
|
1587
|
-
if len(existing.via) < 4 and not any(
|
|
1588
|
-
v.edge_type == edge.edge_type and v.from_fqn == edge.from_fqn
|
|
1589
|
-
for v in existing.via
|
|
1590
|
-
):
|
|
1591
|
-
existing.via.append(edge)
|
|
1592
|
-
|
|
1593
|
-
for row in self._rows(q, params):
|
|
1594
|
-
_ingest_flow_row(row)
|
|
1595
|
-
if len(stage_results) >= stage_limit:
|
|
1596
|
-
break
|
|
1597
|
-
|
|
1598
|
-
# Structural-first budget: same-microservice CALLS top up first,
|
|
1599
|
-
# then cross-service HTTP/ASYNC caller edges.
|
|
1600
|
-
if follow_calls and len(stage_results) < stage_limit:
|
|
1601
|
-
remaining = stage_limit - len(stage_results)
|
|
1602
|
-
params_cf: dict[str, Any] = {
|
|
1603
|
-
"fqns": current_frontier,
|
|
1604
|
-
"roles": list(stage_roles),
|
|
1605
|
-
"mc": float(min_call_confidence),
|
|
1606
|
-
}
|
|
1607
|
-
scope_cf = _scope_filters(
|
|
1608
|
-
"n", module=module, microservice=microservice, params=params_cf,
|
|
1609
|
-
)
|
|
1610
|
-
sccf = (" AND " + " AND ".join(scope_cf)) if scope_cf else ""
|
|
1611
|
-
qcf = (
|
|
1612
|
-
"MATCH (root:Symbol)-[:DECLARES]->(m1:Symbol)-[c:CALLS]->(m2:Symbol)"
|
|
1613
|
-
"<-[:DECLARES]-(n:Symbol) WHERE root.fqn IN $fqns AND n.role IN $roles "
|
|
1614
|
-
"AND root.microservice = n.microservice "
|
|
1615
|
-
"AND n.resolved AND n.kind IN ['class','interface','enum','record','annotation'] "
|
|
1616
|
-
f"AND c.confidence >= $mc{sccf} "
|
|
1617
|
-
f"RETURN {_symbol_return_for('n')}, 'CALLS' AS edge_type, root.fqn AS from_fqn "
|
|
1618
|
-
f"LIMIT {max(1, remaining * 4)}"
|
|
1619
|
-
)
|
|
1620
|
-
for row in self._rows(qcf, params_cf):
|
|
1621
|
-
_ingest_flow_row(row, filter_external_fqn=True)
|
|
1622
|
-
if len(stage_results) >= stage_limit:
|
|
1623
|
-
break
|
|
1624
|
-
if follow_calls and len(stage_results) < stage_limit:
|
|
1625
|
-
remaining = stage_limit - len(stage_results)
|
|
1626
|
-
params_rf: dict[str, Any] = {
|
|
1627
|
-
"fqns": current_frontier,
|
|
1628
|
-
"roles": list(stage_roles),
|
|
1629
|
-
"mc": float(min_call_confidence),
|
|
1630
|
-
}
|
|
1631
|
-
scope_rf = _scope_filters(
|
|
1632
|
-
"n", module=module, microservice=microservice, params=params_rf,
|
|
1633
|
-
)
|
|
1634
|
-
scrf = (" AND " + " AND ".join(scope_rf)) if scope_rf else ""
|
|
1635
|
-
qrf = (
|
|
1636
|
-
"MATCH (root:Symbol)-[:DECLARES]->(m1:Symbol)-[:DECLARES_CLIENT]->(c:Client)"
|
|
1637
|
-
"-[e:HTTP_CALLS]->(rt:Route)<-[:EXPOSES]-(handler:Symbol)<-[:DECLARES]-(n:Symbol) "
|
|
1638
|
-
"WHERE root.fqn IN $fqns AND n.role IN $roles "
|
|
1639
|
-
"AND n.resolved AND n.kind IN ['class','interface','enum','record','annotation'] "
|
|
1640
|
-
"AND e.confidence >= $mc AND root.microservice <> n.microservice "
|
|
1641
|
-
f"{scrf} "
|
|
1642
|
-
f"RETURN {_symbol_return_for('n')}, 'HTTP_CALLS' AS edge_type, "
|
|
1643
|
-
f"root.fqn AS from_fqn, c.id AS caller_client_id "
|
|
1644
|
-
f"LIMIT {max(1, remaining * 4)}"
|
|
1645
|
-
)
|
|
1646
|
-
for row in self._rows(qrf, params_rf):
|
|
1647
|
-
_ingest_flow_row(row, filter_external_fqn=True)
|
|
1648
|
-
if len(stage_results) >= stage_limit:
|
|
1649
|
-
break
|
|
1650
|
-
if len(stage_results) < stage_limit:
|
|
1651
|
-
remaining = stage_limit - len(stage_results)
|
|
1652
|
-
qrf_async = (
|
|
1653
|
-
"MATCH (root:Symbol)-[:DECLARES]->(m1:Symbol)-[:DECLARES_PRODUCER]->(pr:Producer)"
|
|
1654
|
-
"-[e:ASYNC_CALLS]->(rt:Route)<-[:EXPOSES]-(handler:Symbol)<-[:DECLARES]-(n:Symbol) "
|
|
1655
|
-
"WHERE root.fqn IN $fqns AND n.role IN $roles "
|
|
1656
|
-
"AND n.resolved AND n.kind IN ['class','interface','enum','record','annotation'] "
|
|
1657
|
-
"AND e.confidence >= $mc AND root.microservice <> n.microservice "
|
|
1658
|
-
f"{scrf} "
|
|
1659
|
-
f"RETURN {_symbol_return_for('n')}, 'ASYNC_CALLS' AS edge_type, "
|
|
1660
|
-
f"root.fqn AS from_fqn, pr.id AS caller_producer_id "
|
|
1661
|
-
f"LIMIT {max(1, remaining * 4)}"
|
|
1662
|
-
)
|
|
1663
|
-
for row in self._rows(qrf_async, params_rf):
|
|
1664
|
-
_ingest_flow_row(row, filter_external_fqn=True)
|
|
1665
|
-
if len(stage_results) >= stage_limit:
|
|
1666
|
-
break
|
|
1667
|
-
|
|
1668
|
-
current_frontier = next_frontier
|
|
1669
|
-
if len(stage_results) >= stage_limit:
|
|
1670
|
-
break
|
|
1671
|
-
|
|
1672
|
-
if not stage_results:
|
|
1673
|
-
break
|
|
1674
|
-
stage_list = list(stage_results.values())
|
|
1675
|
-
stages.append(stage_list)
|
|
1676
|
-
for entry in stage_list:
|
|
1677
|
-
visited_fqns.add(entry.symbol.fqn)
|
|
1678
|
-
frontier_fqns = [entry.symbol.fqn for entry in stage_list]
|
|
1679
|
-
return stages
|
|
1680
|
-
|
|
1681
|
-
# ---- routes (B2a) ----
|
|
1682
|
-
|
|
1683
|
-
_ROUTE_RETURN = (
|
|
1684
|
-
"r.id AS id, r.kind AS kind, r.framework AS framework, r.method AS method, "
|
|
1685
|
-
"r.path AS path, r.path_template AS path_template, r.path_regex AS path_regex, "
|
|
1686
|
-
"r.topic AS topic, r.broker AS broker, r.feign_name AS feign_name, r.feign_url AS feign_url, "
|
|
1687
|
-
"r.microservice AS microservice, r.module AS module, r.filename AS filename, "
|
|
1688
|
-
"r.start_line AS start_line, r.end_line AS end_line, r.resolved AS resolved"
|
|
1689
|
-
)
|
|
1690
|
-
|
|
1691
|
-
@staticmethod
|
|
1692
|
-
def _row_to_route_dict(row: dict[str, Any]) -> dict[str, Any]:
|
|
1693
|
-
return {
|
|
1694
|
-
"id": str(row.get("id") or ""),
|
|
1695
|
-
"kind": str(row.get("kind") or ""),
|
|
1696
|
-
"framework": str(row.get("framework") or ""),
|
|
1697
|
-
"method": str(row.get("method") or ""),
|
|
1698
|
-
"path": str(row.get("path") or ""),
|
|
1699
|
-
"path_template": str(row.get("path_template") or ""),
|
|
1700
|
-
"path_regex": str(row.get("path_regex") or ""),
|
|
1701
|
-
"topic": str(row.get("topic") or ""),
|
|
1702
|
-
"broker": str(row.get("broker") or ""),
|
|
1703
|
-
"feign_name": str(row.get("feign_name") or ""),
|
|
1704
|
-
"feign_url": str(row.get("feign_url") or ""),
|
|
1705
|
-
"microservice": str(row.get("microservice") or ""),
|
|
1706
|
-
"module": str(row.get("module") or ""),
|
|
1707
|
-
"filename": str(row.get("filename") or ""),
|
|
1708
|
-
"start_line": int(row.get("start_line") or 0),
|
|
1709
|
-
"end_line": int(row.get("end_line") or 0),
|
|
1710
|
-
"resolved": bool(row.get("resolved", True)),
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
def list_routes(
|
|
1714
|
-
self,
|
|
1715
|
-
*,
|
|
1716
|
-
microservice: str | None = None,
|
|
1717
|
-
framework: str | None = None,
|
|
1718
|
-
path_contains: str | None = None,
|
|
1719
|
-
method: str | None = None,
|
|
1720
|
-
limit: int = 100,
|
|
1721
|
-
server_exposed: bool = False,
|
|
1722
|
-
include_kafka: bool = True,
|
|
1723
|
-
) -> list[dict[str, Any]]:
|
|
1724
|
-
lim = max(1, min(int(limit), 500))
|
|
1725
|
-
params: dict[str, Any] = {"lim": lim}
|
|
1726
|
-
preds: list[str] = []
|
|
1727
|
-
if microservice:
|
|
1728
|
-
params["microservice"] = microservice
|
|
1729
|
-
preds.append("r.microservice = $microservice")
|
|
1730
|
-
if framework:
|
|
1731
|
-
params["framework"] = framework
|
|
1732
|
-
preds.append("r.framework = $framework")
|
|
1733
|
-
if path_contains:
|
|
1734
|
-
params["path_contains"] = path_contains
|
|
1735
|
-
preds.append("r.path CONTAINS $path_contains")
|
|
1736
|
-
if method is not None and method != "":
|
|
1737
|
-
params["method"] = method
|
|
1738
|
-
preds.append("r.method = $method")
|
|
1739
|
-
if server_exposed:
|
|
1740
|
-
# `routes` CLI surface: only server-exposed entrypoints. The inbound
|
|
1741
|
-
# EXPOSES clause is what makes a route "exposed" by a Symbol — a
|
|
1742
|
-
# client http_endpoint mirror is a call-site reached via HTTP_CALLS,
|
|
1743
|
-
# never EXPOSES'd, so it drops out here regardless of include_kafka.
|
|
1744
|
-
# Kafka topics (kind=kafka_topic) live under the `topics` command;
|
|
1745
|
-
# include_kafka=False (the CLI default) excludes them, while the
|
|
1746
|
-
# OR-branch lets `--include-kafka` opt them back in (still excluding
|
|
1747
|
-
# client mirrors, which never carry an inbound EXPOSES).
|
|
1748
|
-
if include_kafka:
|
|
1749
|
-
preds.append(
|
|
1750
|
-
"(r.kind = 'kafka_topic' OR EXISTS { MATCH (s:Symbol)-[:EXPOSES]->(r) })"
|
|
1751
|
-
)
|
|
1752
|
-
else:
|
|
1753
|
-
preds.append("r.kind = 'http_endpoint'")
|
|
1754
|
-
preds.append("EXISTS { MATCH (s:Symbol)-[:EXPOSES]->(r) }")
|
|
1755
|
-
where = (" WHERE " + " AND ".join(preds)) if preds else ""
|
|
1756
|
-
q = (
|
|
1757
|
-
f"MATCH (r:Route){where} RETURN {self._ROUTE_RETURN} "
|
|
1758
|
-
f"ORDER BY r.framework, r.path, r.id LIMIT $lim"
|
|
1759
|
-
)
|
|
1760
|
-
return [self._row_to_route_dict(r) for r in self._rows(q, params)]
|
|
1761
|
-
|
|
1762
|
-
def find_route_handlers(self, *, route_id: str) -> list[dict[str, Any]]:
|
|
1763
|
-
s_proj = ", ".join(f"s.{c} AS s_{c}" for c in _SYM_COLS)
|
|
1764
|
-
q = (
|
|
1765
|
-
f"MATCH (s:Symbol)-[e:EXPOSES]->(r:Route) WHERE r.id = $rid "
|
|
1766
|
-
f"RETURN {s_proj}, e.confidence AS confidence, e.strategy AS strategy "
|
|
1767
|
-
f"ORDER BY s.fqn"
|
|
1768
|
-
)
|
|
1769
|
-
out: list[dict[str, Any]] = []
|
|
1770
|
-
for r in self._rows(q, {"rid": route_id}):
|
|
1771
|
-
sym = _row_to_symbol({k[2:]: v for k, v in r.items() if k.startswith("s_")})
|
|
1772
|
-
out.append({
|
|
1773
|
-
"symbol": asdict(sym),
|
|
1774
|
-
"confidence": float(r.get("confidence") or 0.0),
|
|
1775
|
-
"strategy": str(r.get("strategy") or ""),
|
|
1776
|
-
})
|
|
1777
|
-
return out
|
|
1778
|
-
|
|
1779
|
-
def get_route_by_path(
|
|
1780
|
-
self,
|
|
1781
|
-
*,
|
|
1782
|
-
microservice: str,
|
|
1783
|
-
path_template: str,
|
|
1784
|
-
method: str = "",
|
|
1785
|
-
) -> dict[str, Any] | None:
|
|
1786
|
-
params: dict[str, Any] = {"ms": microservice, "pt": path_template}
|
|
1787
|
-
meth_filter = ""
|
|
1788
|
-
if method != "":
|
|
1789
|
-
params["meth"] = method
|
|
1790
|
-
meth_filter = "AND r.method = $meth"
|
|
1791
|
-
q = (
|
|
1792
|
-
f"MATCH (r:Route) WHERE r.microservice = $ms AND r.path_template = $pt {meth_filter} "
|
|
1793
|
-
f"RETURN {self._ROUTE_RETURN} ORDER BY r.id LIMIT 1"
|
|
1794
|
-
)
|
|
1795
|
-
rows = self._rows(q, params)
|
|
1796
|
-
if not rows:
|
|
1797
|
-
return None
|
|
1798
|
-
return self._row_to_route_dict(rows[0])
|
|
1799
|
-
|
|
1800
|
-
def find_route_callers(
|
|
1801
|
-
self,
|
|
1802
|
-
route_id: str | None = None,
|
|
1803
|
-
*,
|
|
1804
|
-
microservice: str = "",
|
|
1805
|
-
path_template: str = "",
|
|
1806
|
-
method: str = "",
|
|
1807
|
-
) -> list[RouteCaller]:
|
|
1808
|
-
"""HTTP callers via Client; async callers via Producer (two-hop each).
|
|
1809
|
-
|
|
1810
|
-
Mirror-agnostic: cross-service ``HTTP_CALLS``/``ASYNC_CALLS`` edges
|
|
1811
|
-
sometimes terminate at a client-side mirror Route rather than the
|
|
1812
|
-
server Route by id (the enrichment links a Client to whichever Route
|
|
1813
|
-
node it could resolve — a mirror when the server route wasn't found),
|
|
1814
|
-
so the strict edge query can return 0 rows for genuine cross-service
|
|
1815
|
-
callers. We therefore UNION edge-based results (which carry
|
|
1816
|
-
confidence/match/raw_uri metadata) with path/topic-based results that
|
|
1817
|
-
match ``Client``/``Producer`` nodes directly against the entry route's
|
|
1818
|
-
``microservice`` + ``path_template``/``topic``, deduping by
|
|
1819
|
-
``caller_node_id`` so edge-based rows win and path-matched rows only
|
|
1820
|
-
fill the gaps (default ``confidence=1.0``, ``match='path_template'``).
|
|
1821
|
-
"""
|
|
1822
|
-
rid = route_id or ""
|
|
1823
|
-
if not rid:
|
|
1824
|
-
params: dict[str, Any] = {
|
|
1825
|
-
"microservice": microservice,
|
|
1826
|
-
"path_template": path_template,
|
|
1827
|
-
"method": method,
|
|
1828
|
-
}
|
|
1829
|
-
rows = self._rows(
|
|
1830
|
-
"MATCH (r:Route) "
|
|
1831
|
-
"WHERE r.microservice = $microservice AND r.path_template = $path_template AND r.method = $method "
|
|
1832
|
-
"RETURN r.id AS id LIMIT 1",
|
|
1833
|
-
params,
|
|
1834
|
-
)
|
|
1835
|
-
if not rows:
|
|
1836
|
-
return []
|
|
1837
|
-
rid = str(rows[0].get("id") or "")
|
|
1838
|
-
if not rid:
|
|
1839
|
-
return []
|
|
1840
|
-
# Edge-based HTTP callers — carry confidence/match/raw_uri metadata.
|
|
1841
|
-
http_edge_rows = self._rows(
|
|
1842
|
-
"MATCH (s:Symbol)-[:DECLARES_CLIENT]->(c:Client)-[e:HTTP_CALLS]->(r:Route {id: $rid}) "
|
|
1843
|
-
"RETURN c.id AS caller_node_id, c.microservice AS caller_microservice, "
|
|
1844
|
-
"s.id AS declaring_symbol_id, s.fqn AS declaring_symbol_fqn, e.confidence AS confidence, e.match AS match, "
|
|
1845
|
-
"c.target_service AS target_service, e.raw_uri AS raw_uri",
|
|
1846
|
-
{"rid": rid},
|
|
1847
|
-
)
|
|
1848
|
-
# Path-based HTTP callers (mirror-agnostic): match Client nodes by the
|
|
1849
|
-
# entry route's microservice + path_template + method regardless of
|
|
1850
|
-
# whether an HTTP_CALLS edge reaches this Route node.
|
|
1851
|
-
http_path_rows = self._rows(
|
|
1852
|
-
"MATCH (entry:Route {id: $rid}) "
|
|
1853
|
-
"MATCH (s:Symbol)-[:DECLARES_CLIENT]->(c:Client) "
|
|
1854
|
-
"WHERE (entry.microservice = '' OR c.target_service = entry.microservice) "
|
|
1855
|
-
"AND ("
|
|
1856
|
-
"(entry.path_template <> '' AND c.path_template = entry.path_template) OR "
|
|
1857
|
-
"(entry.path <> '' AND c.path = entry.path) OR "
|
|
1858
|
-
"(entry.path_template <> '' AND c.path = entry.path_template) OR "
|
|
1859
|
-
"(entry.path <> '' AND c.path_template = entry.path)"
|
|
1860
|
-
") "
|
|
1861
|
-
"AND (entry.method = '' OR c.method = entry.method OR c.method = '') "
|
|
1862
|
-
"RETURN c.id AS caller_node_id, c.microservice AS caller_microservice, "
|
|
1863
|
-
"s.id AS declaring_symbol_id, s.fqn AS declaring_symbol_fqn, c.target_service AS target_service, "
|
|
1864
|
-
"c.path AS raw_uri",
|
|
1865
|
-
{"rid": rid},
|
|
1866
|
-
)
|
|
1867
|
-
# Edge-based async callers — carry confidence/match metadata.
|
|
1868
|
-
async_edge_rows = self._rows(
|
|
1869
|
-
"MATCH (s:Symbol)-[:DECLARES_PRODUCER]->(p:Producer)-[e:ASYNC_CALLS]->(r:Route {id: $rid}) "
|
|
1870
|
-
"RETURN p.id AS caller_node_id, p.microservice AS caller_microservice, "
|
|
1871
|
-
"s.id AS declaring_symbol_id, s.fqn AS declaring_symbol_fqn, e.confidence AS confidence, e.match AS match, "
|
|
1872
|
-
"p.topic AS topic, p.broker AS broker",
|
|
1873
|
-
{"rid": rid},
|
|
1874
|
-
)
|
|
1875
|
-
# Topic-based async callers (mirror-agnostic): match Producer nodes by
|
|
1876
|
-
# the entry route's topic (kafka_topic routes).
|
|
1877
|
-
async_path_rows = self._rows(
|
|
1878
|
-
"MATCH (entry:Route {id: $rid}) "
|
|
1879
|
-
"MATCH (s:Symbol)-[:DECLARES_PRODUCER]->(p:Producer) "
|
|
1880
|
-
"WHERE entry.topic <> '' AND p.topic = entry.topic "
|
|
1881
|
-
"RETURN p.id AS caller_node_id, p.microservice AS caller_microservice, "
|
|
1882
|
-
"s.id AS declaring_symbol_id, s.fqn AS declaring_symbol_fqn, p.topic AS topic, p.broker AS broker",
|
|
1883
|
-
{"rid": rid},
|
|
1884
|
-
)
|
|
1885
|
-
|
|
1886
|
-
# Dedup by caller_node_id: edge-based rows (with metadata) win; the
|
|
1887
|
-
# path/topic-based rows only fill gaps with default metadata so the
|
|
1888
|
-
# same caller is never reported twice.
|
|
1889
|
-
http_by_id: dict[str, dict[str, Any]] = {}
|
|
1890
|
-
for row in http_edge_rows:
|
|
1891
|
-
cid = str(row.get("caller_node_id") or "")
|
|
1892
|
-
if not cid:
|
|
1893
|
-
continue
|
|
1894
|
-
http_by_id[cid] = {
|
|
1895
|
-
"caller_node_id": cid,
|
|
1896
|
-
"caller_microservice": str(row.get("caller_microservice") or ""),
|
|
1897
|
-
"declaring_symbol_id": str(row.get("declaring_symbol_id") or ""),
|
|
1898
|
-
"declaring_symbol_fqn": str(row.get("declaring_symbol_fqn") or ""),
|
|
1899
|
-
"confidence": float(row.get("confidence") or 0.0),
|
|
1900
|
-
"match": str(row.get("match") or ""),
|
|
1901
|
-
"target_service": str(row.get("target_service") or ""),
|
|
1902
|
-
"raw_uri": str(row.get("raw_uri") or ""),
|
|
1903
|
-
}
|
|
1904
|
-
for row in http_path_rows:
|
|
1905
|
-
cid = str(row.get("caller_node_id") or "")
|
|
1906
|
-
if not cid or cid in http_by_id:
|
|
1907
|
-
continue
|
|
1908
|
-
http_by_id[cid] = {
|
|
1909
|
-
"caller_node_id": cid,
|
|
1910
|
-
"caller_microservice": str(row.get("caller_microservice") or ""),
|
|
1911
|
-
"declaring_symbol_id": str(row.get("declaring_symbol_id") or ""),
|
|
1912
|
-
"declaring_symbol_fqn": str(row.get("declaring_symbol_fqn") or ""),
|
|
1913
|
-
"confidence": 1.0,
|
|
1914
|
-
"match": "path_template",
|
|
1915
|
-
"target_service": str(row.get("target_service") or ""),
|
|
1916
|
-
"raw_uri": str(row.get("raw_uri") or ""),
|
|
1917
|
-
}
|
|
1918
|
-
async_by_id: dict[str, dict[str, Any]] = {}
|
|
1919
|
-
for row in async_edge_rows:
|
|
1920
|
-
pid = str(row.get("caller_node_id") or "")
|
|
1921
|
-
if not pid:
|
|
1922
|
-
continue
|
|
1923
|
-
async_by_id[pid] = {
|
|
1924
|
-
"caller_node_id": pid,
|
|
1925
|
-
"caller_microservice": str(row.get("caller_microservice") or ""),
|
|
1926
|
-
"declaring_symbol_id": str(row.get("declaring_symbol_id") or ""),
|
|
1927
|
-
"declaring_symbol_fqn": str(row.get("declaring_symbol_fqn") or ""),
|
|
1928
|
-
"confidence": float(row.get("confidence") or 0.0),
|
|
1929
|
-
"match": str(row.get("match") or ""),
|
|
1930
|
-
"topic": str(row.get("topic") or ""),
|
|
1931
|
-
"broker": str(row.get("broker") or ""),
|
|
1932
|
-
}
|
|
1933
|
-
for row in async_path_rows:
|
|
1934
|
-
pid = str(row.get("caller_node_id") or "")
|
|
1935
|
-
if not pid or pid in async_by_id:
|
|
1936
|
-
continue
|
|
1937
|
-
async_by_id[pid] = {
|
|
1938
|
-
"caller_node_id": pid,
|
|
1939
|
-
"caller_microservice": str(row.get("caller_microservice") or ""),
|
|
1940
|
-
"declaring_symbol_id": str(row.get("declaring_symbol_id") or ""),
|
|
1941
|
-
"declaring_symbol_fqn": str(row.get("declaring_symbol_fqn") or ""),
|
|
1942
|
-
"confidence": 1.0,
|
|
1943
|
-
"match": "topic",
|
|
1944
|
-
"topic": str(row.get("topic") or ""),
|
|
1945
|
-
"broker": str(row.get("broker") or ""),
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
out: list[RouteCaller] = []
|
|
1949
|
-
for row in sorted(http_by_id.values(), key=lambda r: (-r["confidence"], r["caller_node_id"])):
|
|
1950
|
-
out.append(
|
|
1951
|
-
RouteCaller(
|
|
1952
|
-
caller_node_id=row["caller_node_id"],
|
|
1953
|
-
caller_node_kind="client",
|
|
1954
|
-
caller_microservice=row["caller_microservice"],
|
|
1955
|
-
declaring_symbol_id=row["declaring_symbol_id"],
|
|
1956
|
-
declaring_symbol_fqn=row["declaring_symbol_fqn"],
|
|
1957
|
-
confidence=row["confidence"],
|
|
1958
|
-
match=row["match"],
|
|
1959
|
-
target_service=row["target_service"],
|
|
1960
|
-
raw_uri=row["raw_uri"],
|
|
1961
|
-
),
|
|
1962
|
-
)
|
|
1963
|
-
for row in sorted(async_by_id.values(), key=lambda r: (-r["confidence"], r["caller_node_id"])):
|
|
1964
|
-
out.append(
|
|
1965
|
-
RouteCaller(
|
|
1966
|
-
caller_node_id=row["caller_node_id"],
|
|
1967
|
-
caller_node_kind="producer",
|
|
1968
|
-
caller_microservice=row["caller_microservice"],
|
|
1969
|
-
declaring_symbol_id=row["declaring_symbol_id"],
|
|
1970
|
-
declaring_symbol_fqn=row["declaring_symbol_fqn"],
|
|
1971
|
-
confidence=row["confidence"],
|
|
1972
|
-
match=row["match"],
|
|
1973
|
-
topic=row["topic"],
|
|
1974
|
-
broker=row["broker"],
|
|
1975
|
-
),
|
|
1976
|
-
)
|
|
1977
|
-
return out
|
|
1978
|
-
|
|
1979
|
-
def trace_request_flow(self, entry_route_id: str, max_hops: int = 5) -> dict[str, Any]:
|
|
1980
|
-
"""Inbound HTTP via Client; async inbound via Producer (two-hop each).
|
|
1981
|
-
|
|
1982
|
-
Mirror-agnostic (see ``find_route_callers``): cross-service
|
|
1983
|
-
``HTTP_CALLS``/``ASYNC_CALLS`` edges may land on a client-side mirror
|
|
1984
|
-
Route rather than the server Route by id, so the edge-based inbound
|
|
1985
|
-
query is UNIONED with a path/topic-based query that matches
|
|
1986
|
-
``Client``/``Producer`` nodes against the entry route's
|
|
1987
|
-
``microservice`` + ``path_template``/``topic``. Rows are deduped by
|
|
1988
|
-
``caller_node_id`` (edge-based win) so each inbound caller appears once.
|
|
1989
|
-
"""
|
|
1990
|
-
hops = max(1, min(int(max_hops), 8))
|
|
1991
|
-
inbound_http_edge = self._rows(
|
|
1992
|
-
f"MATCH (entry:Route {{id: $rid}})<-[e:HTTP_CALLS]-(caller:Client)"
|
|
1993
|
-
"<-[:DECLARES_CLIENT]-(decl:Symbol) "
|
|
1994
|
-
f"OPTIONAL MATCH (origin:Symbol)-[:CALLS*0..{hops}]->(decl) "
|
|
1995
|
-
"RETURN DISTINCT caller.id AS caller_node_id, 'client' AS caller_node_kind, "
|
|
1996
|
-
"decl.id AS declaring_symbol_id, decl.fqn AS declaring_symbol_fqn, "
|
|
1997
|
-
"caller.microservice AS microservice, e.confidence AS confidence, "
|
|
1998
|
-
"e.match AS match, origin.id AS origin_symbol_id, origin.fqn AS origin_fqn",
|
|
1999
|
-
{"rid": entry_route_id},
|
|
2000
|
-
)
|
|
2001
|
-
inbound_http_path = self._rows(
|
|
2002
|
-
"MATCH (entry:Route {id: $rid}) "
|
|
2003
|
-
"MATCH (caller:Client)<-[:DECLARES_CLIENT]-(decl:Symbol) "
|
|
2004
|
-
"WHERE (entry.microservice = '' OR caller.target_service = entry.microservice) "
|
|
2005
|
-
"AND ("
|
|
2006
|
-
"(entry.path_template <> '' AND caller.path_template = entry.path_template) OR "
|
|
2007
|
-
"(entry.path <> '' AND caller.path = entry.path) OR "
|
|
2008
|
-
"(entry.path_template <> '' AND caller.path = entry.path_template) OR "
|
|
2009
|
-
"(entry.path <> '' AND caller.path_template = entry.path)"
|
|
2010
|
-
") "
|
|
2011
|
-
"AND (entry.method = '' OR caller.method = entry.method OR caller.method = '') "
|
|
2012
|
-
f"OPTIONAL MATCH (origin:Symbol)-[:CALLS*0..{hops}]->(decl) "
|
|
2013
|
-
"RETURN DISTINCT caller.id AS caller_node_id, 'client' AS caller_node_kind, "
|
|
2014
|
-
"decl.id AS declaring_symbol_id, decl.fqn AS declaring_symbol_fqn, "
|
|
2015
|
-
"caller.microservice AS microservice, 1.0 AS confidence, "
|
|
2016
|
-
"'path_template' AS match, origin.id AS origin_symbol_id, origin.fqn AS origin_fqn",
|
|
2017
|
-
{"rid": entry_route_id},
|
|
2018
|
-
)
|
|
2019
|
-
inbound_async_edge = self._rows(
|
|
2020
|
-
f"MATCH (entry:Route {{id: $rid}})<-[e:ASYNC_CALLS]-(caller:Producer)"
|
|
2021
|
-
"<-[:DECLARES_PRODUCER]-(decl:Symbol) "
|
|
2022
|
-
f"OPTIONAL MATCH (origin:Symbol)-[:CALLS*0..{hops}]->(decl) "
|
|
2023
|
-
"RETURN DISTINCT caller.id AS caller_node_id, 'producer' AS caller_node_kind, "
|
|
2024
|
-
"decl.id AS declaring_symbol_id, decl.fqn AS declaring_symbol_fqn, "
|
|
2025
|
-
"caller.microservice AS microservice, e.confidence AS confidence, "
|
|
2026
|
-
"e.match AS match, origin.id AS origin_symbol_id, origin.fqn AS origin_fqn",
|
|
2027
|
-
{"rid": entry_route_id},
|
|
2028
|
-
)
|
|
2029
|
-
inbound_async_path = self._rows(
|
|
2030
|
-
"MATCH (entry:Route {id: $rid}) "
|
|
2031
|
-
"MATCH (caller:Producer)<-[:DECLARES_PRODUCER]-(decl:Symbol) "
|
|
2032
|
-
"WHERE entry.topic <> '' AND caller.topic = entry.topic "
|
|
2033
|
-
f"OPTIONAL MATCH (origin:Symbol)-[:CALLS*0..{hops}]->(decl) "
|
|
2034
|
-
"RETURN DISTINCT caller.id AS caller_node_id, 'producer' AS caller_node_kind, "
|
|
2035
|
-
"decl.id AS declaring_symbol_id, decl.fqn AS declaring_symbol_fqn, "
|
|
2036
|
-
"caller.microservice AS microservice, 1.0 AS confidence, "
|
|
2037
|
-
"'topic' AS match, origin.id AS origin_symbol_id, origin.fqn AS origin_fqn",
|
|
2038
|
-
{"rid": entry_route_id},
|
|
2039
|
-
)
|
|
2040
|
-
# Dedup by caller_node_id (edge-based rows carry real confidence/match;
|
|
2041
|
-
# path-based rows fill gaps). Preserve the origin columns from whichever
|
|
2042
|
-
# row wins (the OPTIONAL MATCH over CALLS is the same for both).
|
|
2043
|
-
inbound: list[dict[str, Any]] = []
|
|
2044
|
-
seen: set[str] = set()
|
|
2045
|
-
for row in inbound_http_edge + inbound_http_path + inbound_async_edge + inbound_async_path:
|
|
2046
|
-
cid = str(row.get("caller_node_id") or "")
|
|
2047
|
-
if not cid or cid in seen:
|
|
2048
|
-
continue
|
|
2049
|
-
seen.add(cid)
|
|
2050
|
-
inbound.append(row)
|
|
2051
|
-
inbound.sort(
|
|
2052
|
-
key=lambda r: (-float(r.get("confidence") or 0.0), str(r.get("caller_node_id") or ""))
|
|
2053
|
-
)
|
|
2054
|
-
outbound = self._rows(
|
|
2055
|
-
f"MATCH (handler:Symbol)-[:EXPOSES]->(entry:Route {{id: $rid}}) "
|
|
2056
|
-
f"OPTIONAL MATCH (handler)-[:CALLS*0..{hops}]->(next:Symbol) "
|
|
2057
|
-
"RETURN DISTINCT handler.id AS handler_symbol_id, handler.fqn AS handler_fqn, "
|
|
2058
|
-
"handler.microservice AS handler_microservice, "
|
|
2059
|
-
"next.id AS next_symbol_id, next.fqn AS next_fqn, next.microservice AS next_microservice "
|
|
2060
|
-
"ORDER BY handler_symbol_id, next_symbol_id",
|
|
2061
|
-
{"rid": entry_route_id},
|
|
2062
|
-
)
|
|
2063
|
-
return {
|
|
2064
|
-
"entry_route_id": entry_route_id,
|
|
2065
|
-
"max_hops": hops,
|
|
2066
|
-
"inbound": inbound,
|
|
2067
|
-
"outbound": outbound,
|
|
2068
|
-
}
|
|
2069
|
-
|
|
2070
|
-
# ---- outbound clients (LC3) ----
|
|
2071
|
-
|
|
2072
|
-
_CLIENT_RETURN = (
|
|
2073
|
-
"c.id AS id, c.client_kind AS client_kind, c.target_service AS target_service, "
|
|
2074
|
-
"c.method AS method, c.path AS path, c.path_template AS path_template, "
|
|
2075
|
-
"c.path_regex AS path_regex, c.member_fqn AS member_fqn, c.member_id AS member_id, "
|
|
2076
|
-
"c.microservice AS microservice, c.module AS module, c.filename AS filename, "
|
|
2077
|
-
"c.start_line AS start_line, c.end_line AS end_line, c.resolved AS resolved, "
|
|
2078
|
-
"c.source_layer AS source_layer"
|
|
2079
|
-
)
|
|
2080
|
-
|
|
2081
|
-
@staticmethod
|
|
2082
|
-
def _row_to_client_dict(row: dict[str, Any]) -> dict[str, Any]:
|
|
2083
|
-
return {
|
|
2084
|
-
"id": str(row.get("id") or ""),
|
|
2085
|
-
"client_kind": str(row.get("client_kind") or ""),
|
|
2086
|
-
"target_service": str(row.get("target_service") or ""),
|
|
2087
|
-
"method": str(row.get("method") or ""),
|
|
2088
|
-
"path": str(row.get("path") or ""),
|
|
2089
|
-
"path_template": str(row.get("path_template") or ""),
|
|
2090
|
-
"path_regex": str(row.get("path_regex") or ""),
|
|
2091
|
-
"member_fqn": str(row.get("member_fqn") or ""),
|
|
2092
|
-
"member_id": str(row.get("member_id") or ""),
|
|
2093
|
-
"microservice": str(row.get("microservice") or ""),
|
|
2094
|
-
"module": str(row.get("module") or ""),
|
|
2095
|
-
"filename": str(row.get("filename") or ""),
|
|
2096
|
-
"start_line": int(row.get("start_line") or 0),
|
|
2097
|
-
"end_line": int(row.get("end_line") or 0),
|
|
2098
|
-
"resolved": bool(row.get("resolved", True)),
|
|
2099
|
-
"source_layer": str(row.get("source_layer") or "builtin"),
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
def list_clients(
|
|
2103
|
-
self,
|
|
2104
|
-
*,
|
|
2105
|
-
microservice: str | None = None,
|
|
2106
|
-
client_kind: str | None = None,
|
|
2107
|
-
target_service: str | None = None,
|
|
2108
|
-
path_contains: str | None = None,
|
|
2109
|
-
method: str | None = None,
|
|
2110
|
-
limit: int = 100,
|
|
2111
|
-
) -> list[dict[str, Any]]:
|
|
2112
|
-
lim = max(1, min(int(limit), 500))
|
|
2113
|
-
params: dict[str, Any] = {"lim": lim}
|
|
2114
|
-
preds: list[str] = []
|
|
2115
|
-
if microservice:
|
|
2116
|
-
params["microservice"] = microservice
|
|
2117
|
-
preds.append("c.microservice = $microservice")
|
|
2118
|
-
if client_kind:
|
|
2119
|
-
params["client_kind"] = client_kind
|
|
2120
|
-
preds.append("c.client_kind = $client_kind")
|
|
2121
|
-
if target_service:
|
|
2122
|
-
params["target_service"] = target_service
|
|
2123
|
-
preds.append("c.target_service = $target_service")
|
|
2124
|
-
if path_contains:
|
|
2125
|
-
params["path_contains"] = path_contains
|
|
2126
|
-
preds.append("c.path CONTAINS $path_contains")
|
|
2127
|
-
if method is not None and method != "":
|
|
2128
|
-
params["method"] = method
|
|
2129
|
-
preds.append("c.method = $method")
|
|
2130
|
-
where = (" WHERE " + " AND ".join(preds)) if preds else ""
|
|
2131
|
-
q = (
|
|
2132
|
-
f"MATCH (c:Client){where} RETURN {self._CLIENT_RETURN} "
|
|
2133
|
-
f"ORDER BY c.microservice, c.client_kind, c.path, c.method, c.id LIMIT $lim"
|
|
2134
|
-
)
|
|
2135
|
-
return [self._row_to_client_dict(r) for r in self._rows(q, params)]
|
|
2136
|
-
|
|
2137
|
-
_PRODUCER_RETURN = (
|
|
2138
|
-
"p.id AS id, p.producer_kind AS producer_kind, p.topic AS topic, p.broker AS broker, "
|
|
2139
|
-
"p.direction AS direction, p.member_fqn AS member_fqn, p.member_id AS member_id, "
|
|
2140
|
-
"p.microservice AS microservice, p.module AS module, p.filename AS filename, "
|
|
2141
|
-
"p.start_line AS start_line, p.end_line AS end_line, p.resolved AS resolved, "
|
|
2142
|
-
"p.source_layer AS source_layer"
|
|
2143
|
-
)
|
|
2144
|
-
|
|
2145
|
-
@staticmethod
|
|
2146
|
-
def _row_to_producer_dict(row: dict[str, Any]) -> dict[str, Any]:
|
|
2147
|
-
return {
|
|
2148
|
-
"id": str(row.get("id") or ""),
|
|
2149
|
-
"producer_kind": str(row.get("producer_kind") or ""),
|
|
2150
|
-
"topic": str(row.get("topic") or ""),
|
|
2151
|
-
"broker": str(row.get("broker") or ""),
|
|
2152
|
-
"direction": str(row.get("direction") or ""),
|
|
2153
|
-
"member_fqn": str(row.get("member_fqn") or ""),
|
|
2154
|
-
"member_id": str(row.get("member_id") or ""),
|
|
2155
|
-
"microservice": str(row.get("microservice") or ""),
|
|
2156
|
-
"module": str(row.get("module") or ""),
|
|
2157
|
-
"filename": str(row.get("filename") or ""),
|
|
2158
|
-
"start_line": int(row.get("start_line") or 0),
|
|
2159
|
-
"end_line": int(row.get("end_line") or 0),
|
|
2160
|
-
"resolved": bool(row.get("resolved", True)),
|
|
2161
|
-
"source_layer": str(row.get("source_layer") or "builtin"),
|
|
2162
|
-
}
|
|
2163
|
-
|
|
2164
|
-
def list_producers(
|
|
2165
|
-
self,
|
|
2166
|
-
*,
|
|
2167
|
-
microservice: str | None = None,
|
|
2168
|
-
producer_kind: str | None = None,
|
|
2169
|
-
topic_contains: str | None = None,
|
|
2170
|
-
limit: int = 100,
|
|
2171
|
-
) -> list[dict[str, Any]]:
|
|
2172
|
-
lim = max(1, min(int(limit), 500))
|
|
2173
|
-
params: dict[str, Any] = {"lim": lim}
|
|
2174
|
-
preds: list[str] = []
|
|
2175
|
-
if microservice:
|
|
2176
|
-
params["microservice"] = microservice
|
|
2177
|
-
preds.append("p.microservice = $microservice")
|
|
2178
|
-
if producer_kind:
|
|
2179
|
-
params["producer_kind"] = producer_kind
|
|
2180
|
-
preds.append("p.producer_kind = $producer_kind")
|
|
2181
|
-
if topic_contains:
|
|
2182
|
-
params["topic_contains"] = topic_contains
|
|
2183
|
-
preds.append("p.topic CONTAINS $topic_contains")
|
|
2184
|
-
where = (" WHERE " + " AND ".join(preds)) if preds else ""
|
|
2185
|
-
q = (
|
|
2186
|
-
f"MATCH (p:Producer){where} RETURN {self._PRODUCER_RETURN} "
|
|
2187
|
-
f"ORDER BY p.microservice, p.producer_kind, p.topic, p.id LIMIT $lim"
|
|
2188
|
-
)
|
|
2189
|
-
return [self._row_to_producer_dict(r) for r in self._rows(q, params)]
|
|
2190
|
-
|
|
2191
|
-
# ---- used by search_lancedb.graph_expand ----
|
|
2192
|
-
|
|
2193
|
-
def expand_fqns(self, fqns: list[str], *, depth: int = 1,
|
|
2194
|
-
edge_types: list[str] | None = None,
|
|
2195
|
-
direction: str = "both", limit: int = 200) -> list[str]:
|
|
2196
|
-
"""Return neighbor FQNs (types only) for a batch of starting FQNs."""
|
|
2197
|
-
if not fqns or depth < 1:
|
|
2198
|
-
return []
|
|
2199
|
-
edges = edge_types or ["EXTENDS", "IMPLEMENTS", "INJECTS"]
|
|
2200
|
-
edge_pattern = "|".join(edges)
|
|
2201
|
-
if direction == "out":
|
|
2202
|
-
arrow_l, arrow_r = "-", "->"
|
|
2203
|
-
elif direction == "in":
|
|
2204
|
-
arrow_l, arrow_r = "<-", "-"
|
|
2205
|
-
else:
|
|
2206
|
-
arrow_l, arrow_r = "-", "-"
|
|
2207
|
-
q = (
|
|
2208
|
-
f"MATCH (root:Symbol) WHERE root.fqn IN $fqns "
|
|
2209
|
-
f"MATCH (root){arrow_l}[:{edge_pattern}*1..{int(depth)}]{arrow_r}(n:Symbol) "
|
|
2210
|
-
f"WHERE n.kind IN ['class','interface','enum','record','annotation'] AND n.resolved "
|
|
2211
|
-
f"RETURN DISTINCT n.fqn AS fqn LIMIT {int(limit)}"
|
|
2212
|
-
)
|
|
2213
|
-
return [r["fqn"] for r in self._rows(q, {"fqns": fqns}) if r.get("fqn")]
|