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.
Files changed (77) hide show
  1. java_codebase_rag-0.12.2.dist-info/METADATA +35 -0
  2. java_codebase_rag-0.12.2.dist-info/RECORD +4 -0
  3. {java_codebase_rag-0.12.0.dist-info → java_codebase_rag-0.12.2.dist-info}/WHEEL +1 -1
  4. java_codebase_rag/_deprecation.py +0 -103
  5. java_codebase_rag/_fdlimit.py +0 -56
  6. java_codebase_rag/_stdio.py +0 -32
  7. java_codebase_rag/_version.py +0 -35
  8. java_codebase_rag/absence/__init__.py +0 -0
  9. java_codebase_rag/absence/absence_diagnosis.py +0 -700
  10. java_codebase_rag/absence/absence_types.py +0 -124
  11. java_codebase_rag/absence/absence_vocab.py +0 -460
  12. java_codebase_rag/analysis/__init__.py +0 -0
  13. java_codebase_rag/analysis/pr_analysis.py +0 -563
  14. java_codebase_rag/analysis/resolve_service.py +0 -740
  15. java_codebase_rag/ast/__init__.py +0 -0
  16. java_codebase_rag/ast/ast_java.py +0 -2847
  17. java_codebase_rag/ast/ast_kotlin.py +0 -1794
  18. java_codebase_rag/ast/brownfield_events.py +0 -58
  19. java_codebase_rag/ast/chunk_heuristics.py +0 -83
  20. java_codebase_rag/ast/language.py +0 -117
  21. java_codebase_rag/cli.py +0 -1215
  22. java_codebase_rag/cli_dispatch.py +0 -251
  23. java_codebase_rag/cli_format.py +0 -85
  24. java_codebase_rag/cli_progress.py +0 -94
  25. java_codebase_rag/config.py +0 -833
  26. java_codebase_rag/eval/__init__.py +0 -1
  27. java_codebase_rag/eval/ground_truth.py +0 -100
  28. java_codebase_rag/eval/metrics.py +0 -107
  29. java_codebase_rag/eval/runner.py +0 -556
  30. java_codebase_rag/graph/__init__.py +0 -0
  31. java_codebase_rag/graph/build_ast_graph.py +0 -4593
  32. java_codebase_rag/graph/graph_enrich.py +0 -1940
  33. java_codebase_rag/graph/graph_types.py +0 -224
  34. java_codebase_rag/graph/java_ontology.py +0 -465
  35. java_codebase_rag/graph/ladybug_queries.py +0 -2213
  36. java_codebase_rag/graph/path_filtering.py +0 -509
  37. java_codebase_rag/index/__init__.py +0 -0
  38. java_codebase_rag/index/java_index_flow_lancedb.py +0 -879
  39. java_codebase_rag/index/java_index_v1_common.py +0 -33
  40. java_codebase_rag/install_data/__init__.py +0 -0
  41. java_codebase_rag/install_data/agents/explorer-rag-cli.md +0 -110
  42. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +0 -152
  43. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +0 -165
  44. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +0 -107
  45. java_codebase_rag/installer.py +0 -2188
  46. java_codebase_rag/jrag.py +0 -4545
  47. java_codebase_rag/jrag_envelope.py +0 -1107
  48. java_codebase_rag/jrag_hints.py +0 -204
  49. java_codebase_rag/jrag_render.py +0 -926
  50. java_codebase_rag/lance_optimize.py +0 -264
  51. java_codebase_rag/mcp/__init__.py +0 -0
  52. java_codebase_rag/mcp/mcp_hints.py +0 -932
  53. java_codebase_rag/mcp/mcp_v2.py +0 -1916
  54. java_codebase_rag/mcp/server.py +0 -886
  55. java_codebase_rag/pipeline.py +0 -531
  56. java_codebase_rag/progress.py +0 -570
  57. java_codebase_rag/read_payloads.py +0 -781
  58. java_codebase_rag/search/__init__.py +0 -0
  59. java_codebase_rag/search/index_common.py +0 -10
  60. java_codebase_rag/search/search_lancedb.py +0 -1296
  61. java_codebase_rag/search/search_lexical.py +0 -449
  62. java_codebase_rag/search/search_scoring.py +0 -537
  63. java_codebase_rag/watch/__init__.py +0 -0
  64. java_codebase_rag/watch/client.py +0 -230
  65. java_codebase_rag/watch/daemon.py +0 -396
  66. java_codebase_rag/watch/lock.py +0 -201
  67. java_codebase_rag/watch/paths.py +0 -76
  68. java_codebase_rag/watch/protocol.py +0 -122
  69. java_codebase_rag/watch/server.py +0 -273
  70. java_codebase_rag/watch/warm.py +0 -105
  71. java_codebase_rag/watch/watcher.py +0 -394
  72. java_codebase_rag-0.12.0.dist-info/METADATA +0 -340
  73. java_codebase_rag-0.12.0.dist-info/RECORD +0 -75
  74. java_codebase_rag-0.12.0.dist-info/entry_points.txt +0 -5
  75. java_codebase_rag-0.12.0.dist-info/licenses/LICENSE +0 -21
  76. java_codebase_rag-0.12.0.dist-info/top_level.txt +0 -1
  77. /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")]