java-codebase-rag 0.6.7__py3-none-any.whl → 0.9.0__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 (34) hide show
  1. ast_java.py +8 -3
  2. build_ast_graph.py +72 -16
  3. graph_enrich.py +2 -1
  4. graph_types.py +133 -0
  5. java_codebase_rag/_fdlimit.py +10 -2
  6. java_codebase_rag/_stdio.py +32 -0
  7. java_codebase_rag/cli.py +149 -25
  8. java_codebase_rag/config.py +128 -9
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +148 -0
  10. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +78 -232
  11. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +49 -88
  12. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +183 -0
  13. java_codebase_rag/installer.py +720 -107
  14. java_codebase_rag/jrag.py +4405 -0
  15. java_codebase_rag/jrag_envelope.py +1085 -0
  16. java_codebase_rag/jrag_hints.py +204 -0
  17. java_codebase_rag/jrag_render.py +697 -0
  18. java_codebase_rag/lance_optimize.py +18 -0
  19. java_codebase_rag/pipeline.py +34 -0
  20. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/METADATA +137 -94
  21. java_codebase_rag-0.9.0.dist-info/RECORD +43 -0
  22. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/WHEEL +1 -1
  23. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/entry_points.txt +1 -0
  24. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/top_level.txt +2 -0
  25. java_index_flow_lancedb.py +34 -19
  26. java_ontology.py +12 -0
  27. ladybug_queries.py +233 -52
  28. mcp_hints.py +6 -6
  29. mcp_v2.py +276 -632
  30. resolve_service.py +649 -0
  31. search_lancedb.py +159 -4
  32. server.py +31 -12
  33. java_codebase_rag-0.6.7.dist-info/RECORD +0 -34
  34. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/licenses/LICENSE +0 -0
mcp_v2.py CHANGED
@@ -4,9 +4,9 @@ Strict frame contract
4
4
  ---------------------
5
5
  NodeFilter is a typed predicate bag: each populated field maps to one stored graph
6
6
  attribute for the selected kind; inapplicable fields fail loud with a teaching message.
7
- The ``search`` tool's ``query`` parameter is the ranked-text carve-out; structured
8
- prefix fields (``fqn_prefix``, ``path_prefix``, ``target_path_prefix``) reject ``*``
9
- and ``?`` — see ``_validate_no_wildcards``.
7
+ The ``search`` tool's ``query`` parameter is the ranked-text carve-out; the substring
8
+ fields (``fqn_contains``, ``path_contains``, ``target_path_contains``, ``topic_contains``)
9
+ match literally (Cypher ``CONTAINS``) — no wildcard/metacharacter handling.
10
10
 
11
11
  Revisit trigger (``propose/completed/MCP-FILTER-FRAME-PROPOSE.md`` section 3.4.6)
12
12
  --------------------------------------------------------------
@@ -22,29 +22,59 @@ import os
22
22
  import sys
23
23
  from pathlib import Path
24
24
  import threading
25
- from typing import Annotated, Any, Literal, get_args
25
+ from typing import Annotated, Any, Literal, TYPE_CHECKING, get_args
26
26
 
27
27
  from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, model_validator, validate_call
28
- from sentence_transformers import SentenceTransformer
29
28
 
29
+ if TYPE_CHECKING:
30
+ # Eager import would pull torch at module load. The vector stack is optional (graph-only
31
+ # installs ship without torch/lancedb); it is imported lazily in _get_sentence_transformer.
32
+ from sentence_transformers import SentenceTransformer
33
+
34
+ from graph_types import (
35
+ NodeRef,
36
+ StructuredHint,
37
+ _hints_or_skip,
38
+ _node_ref_from_row,
39
+ _resolve_node_kind,
40
+ _to_structured_hints,
41
+ set_hints_enabled,
42
+ )
30
43
  from index_common import SBERT_MODEL
31
44
  from java_codebase_rag.config import resolved_sbert_model_for_process_env
32
- from java_ontology import EDGE_SCHEMA, ResolveReason
45
+ from java_ontology import EDGE_SCHEMA
33
46
  from ladybug_queries import LadybugGraph, OVERRIDE_AXIS_COMPOSED_EDGE_TYPES
34
- from mcp_hints import generate_hints, MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION
35
- from search_lancedb import TABLES, run_search
36
-
37
- # Module-level flag set by server.py at startup from resolved config.
38
- _hints_enabled: bool = True
39
-
40
-
41
- def set_hints_enabled(enabled: bool) -> None:
42
- global _hints_enabled
43
- _hints_enabled = enabled
44
-
45
-
46
- def _hints_or_skip(tool: str, payload: dict) -> tuple[list, list]:
47
- return generate_hints(tool, payload) if _hints_enabled else ([], [])
47
+ from mcp_hints import MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION
48
+
49
+ # The vector stack (lancedb/torch, reached via search_lancedb) is optional — it is absent on
50
+ # graph-only installs (macOS Intel). Import eagerly when available so ``run_search``/``TABLES``
51
+ # exist as module attributes (tests monkeypatch ``mcp_v2.run_search``; callers use ``TABLES``);
52
+ # fall back to sentinels on ImportError so importing this module never fails and ``search_v2``
53
+ # can return a clean "vector search unavailable" envelope instead of crashing.
54
+ try:
55
+ from search_lancedb import TABLES, run_search
56
+ except ImportError: # graph-only install: no torch/lancedb
57
+ TABLES = {}
58
+ run_search = None
59
+ __all__ = [
60
+ "search_v2",
61
+ "find_v2",
62
+ "describe_v2",
63
+ "neighbors_v2",
64
+ "resolve_v2",
65
+ "SearchOutput",
66
+ "FindOutput",
67
+ "DescribeOutput",
68
+ "NeighborsOutput",
69
+ "ResolveOutput",
70
+ "ResolveCandidate",
71
+ "ResolveStatus",
72
+ "NodeRef",
73
+ "NodeFilter",
74
+ "EdgeFilter",
75
+ "StructuredHint",
76
+ "set_hints_enabled",
77
+ ]
48
78
 
49
79
  DeclarationSymbolKind = Literal["class", "interface", "enum", "record", "annotation", "method", "constructor"]
50
80
 
@@ -119,11 +149,18 @@ _fail_loud_lock = threading.Lock()
119
149
 
120
150
 
121
151
  def _log_fail_loud(category: str) -> None:
122
- """Increment process-local fail-loud counter and emit one stderr line (PR-FRAME-3)."""
152
+ """Increment process-local fail-loud counter and emit one stderr line (PR-FRAME-3).
153
+
154
+ The stderr line is gated on ``JAVA_CODEBASE_RAG_FAIL_LOUD`` (default ``"1"`` =
155
+ emit) so the MCP server keeps its operator diagnostic while the agent-facing
156
+ ``jrag`` CLI (which surfaces the same failure as a clean status:error
157
+ envelope) can run it with the diagnostic silenced.
158
+ """
123
159
  with _fail_loud_lock:
124
160
  _fail_loud_counts[category] = _fail_loud_counts.get(category, 0) + 1
125
161
  n = _fail_loud_counts[category]
126
- print(f"[filter-frame] fail-loud category={category} count={n}", file=sys.stderr, flush=True)
162
+ if os.environ.get("JAVA_CODEBASE_RAG_FAIL_LOUD", "1") != "0":
163
+ print(f"[filter-frame] fail-loud category={category} count={n}", file=sys.stderr, flush=True)
127
164
 
128
165
 
129
166
  def filter_frame_counters() -> dict[str, int]:
@@ -134,6 +171,8 @@ def filter_frame_counters() -> dict[str, int]:
134
171
 
135
172
  def _get_sentence_transformer(model_name: str, device: str | None) -> SentenceTransformer:
136
173
  global _st_model
174
+ from sentence_transformers import SentenceTransformer
175
+
137
176
  with _st_lock:
138
177
  if _st_model is None:
139
178
  _st_model = SentenceTransformer(
@@ -154,26 +193,26 @@ class NodeFilter(BaseModel):
154
193
  exclude_roles: list[Role] | None = None
155
194
  annotation: str | None = None
156
195
  capability: str | None = None
157
- fqn_prefix: str | None = None
196
+ fqn_contains: str | None = None
158
197
  symbol_kind: DeclarationSymbolKind | None = None
159
198
  symbol_kinds: list[DeclarationSymbolKind] | None = None
160
199
  http_method: str | None = Field(
161
200
  default=None,
162
201
  description="HTTP verb (commonly GET/POST/PUT/DELETE/PATCH; user route annotations may yield others).",
163
202
  )
164
- path_prefix: str | None = None
203
+ path_contains: str | None = None
165
204
  framework: Framework | None = None
166
205
  client_kind: ClientKind | None = Field(
167
206
  default=None,
168
207
  description="Outbound HTTP client kind: feign_method, rest_template, or web_client.",
169
208
  )
170
209
  target_service: str | None = None
171
- target_path_prefix: str | None = None
210
+ target_path_contains: str | None = None
172
211
  producer_kind: ProducerKind | None = Field(
173
212
  default=None,
174
213
  description="Outbound async producer kind: kafka_send or stream_bridge_send.",
175
214
  )
176
- topic_prefix: str | None = None
215
+ topic_contains: str | None = None
177
216
 
178
217
 
179
218
  class EdgeFilter(BaseModel):
@@ -213,12 +252,8 @@ _NODEFILTER_FIELD_ORDER: tuple[str, ...] = tuple(NodeFilter.model_fields.keys())
213
252
  _EDGEFILTER_FIELD_ORDER: tuple[str, ...] = tuple(EdgeFilter.model_fields.keys())
214
253
 
215
254
 
216
- class StructuredHint(BaseModel):
217
- label: str = ""
218
- tool: Literal["search", "find", "describe", "neighbors", "resolve"]
219
- args: dict[str, Any]
220
- actionable: bool = True
221
- reason: str = ""
255
+ # StructuredHint is now defined in graph_types.py and imported above
256
+
222
257
 
223
258
  # Populated EdgeFilter field -> EDGE_SCHEMA attribute name used in Cypher pushdown.
224
259
  _EDGEFILTER_FIELD_TO_ATTR: dict[str, str] = {
@@ -240,7 +275,7 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
240
275
  "exclude_roles",
241
276
  "annotation",
242
277
  "capability",
243
- "fqn_prefix",
278
+ "fqn_contains",
244
279
  "symbol_kind",
245
280
  "symbol_kinds",
246
281
  ),
@@ -248,7 +283,7 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
248
283
  "microservice",
249
284
  "module",
250
285
  "http_method",
251
- "path_prefix",
286
+ "path_contains",
252
287
  "framework",
253
288
  ),
254
289
  "client": (
@@ -257,7 +292,7 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
257
292
  "source_layer",
258
293
  "client_kind",
259
294
  "target_service",
260
- "target_path_prefix",
295
+ "target_path_contains",
261
296
  "http_method",
262
297
  ),
263
298
  "producer": (
@@ -265,7 +300,7 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
265
300
  "module",
266
301
  "source_layer",
267
302
  "producer_kind",
268
- "topic_prefix",
303
+ "topic_contains",
269
304
  ),
270
305
  }
271
306
 
@@ -308,20 +343,6 @@ def _nodefilter_applicability_error(
308
343
  )
309
344
 
310
345
 
311
- def _validate_no_wildcards(nf: NodeFilter) -> str | None:
312
- """Reject ``*`` / ``?`` in prefix-match fields; wildcards belong in ``search(query=…)``."""
313
- for field_name in ("fqn_prefix", "path_prefix", "target_path_prefix"):
314
- val = getattr(nf, field_name)
315
- if val is None:
316
- continue
317
- if "*" in val or "?" in val:
318
- return (
319
- f"Wildcards (* and ?) are not supported in structured filter field `{field_name}`; "
320
- "use search(query=...) for ranked text match instead."
321
- )
322
- return None
323
-
324
-
325
346
  def _filter_validation_error_message(exc: ValidationError) -> str:
326
347
  items: list[str] = []
327
348
  for err in exc.errors():
@@ -385,8 +406,7 @@ def _edgefilter_applicability_error(edge_types: list[str], ef: EdgeFilter) -> st
385
406
  return None
386
407
 
387
408
 
388
- def _to_structured_hints(raw: list[Any]) -> list[StructuredHint]:
389
- return [StructuredHint(label=h.label, tool=h.tool, args=h.args, actionable=h.actionable, reason=h.reason) for h in raw]
409
+ # _to_structured_hints is now defined in graph_types.py and imported above
390
410
 
391
411
 
392
412
  def _coerce_edge_filter(
@@ -444,16 +464,13 @@ class SearchHit(BaseModel):
444
464
  microservice: str | None = None
445
465
  module: str | None = None
446
466
  role: str | None = None
467
+ filename: str | None = None
468
+ start_line: int | None = None
469
+ score_components: dict[str, float] | None = None
470
+ chunks: int | None = None
447
471
 
448
472
 
449
- class NodeRef(BaseModel):
450
- id: str
451
- kind: Literal["symbol", "route", "client", "producer", "unresolved_call_site"]
452
- fqn: str
453
- symbol_kind: str | None = None
454
- microservice: str | None = None
455
- module: str | None = None
456
- role: str | None = None
473
+ # NodeRef is now defined in graph_types.py and imported above
457
474
 
458
475
 
459
476
  class NodeRecord(BaseModel):
@@ -517,6 +534,11 @@ class FindOutput(BaseModel):
517
534
  default=None,
518
535
  description="Echoed from the request — the page offset the server applied. None on success=False.",
519
536
  )
537
+ has_more_results: bool | None = Field(
538
+ default=None,
539
+ description="True when additional pages remain beyond offset+limit (more matches exist). "
540
+ "None when unset (e.g. success=False).",
541
+ )
520
542
  advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
521
543
  hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
522
544
 
@@ -537,114 +559,21 @@ class NeighborsOutput(BaseModel):
537
559
  default_factory=list,
538
560
  description="Echo of neighbors(edge_types=...) from the request; empty when success=False.",
539
561
  )
562
+ has_more_results: bool | None = Field(
563
+ default=None,
564
+ description="True when additional pages remain beyond offset+limit. None when unset or "
565
+ "when the single-origin CALLS path paginated in SQL (use unfiltered_calls_count / "
566
+ "calls_row_count there).",
567
+ )
540
568
  advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
541
569
  hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
542
570
 
543
571
 
544
- ResolveStatus = Literal["one", "many", "none"]
545
-
546
- _RESOLVE_CANDIDATE_CAP = 10
547
-
548
- _RESOLVE_REASON_PRIORITY: dict[ResolveReason, int] = {
549
- "exact_id": 0,
550
- "exact_fqn": 1,
551
- "route_method_path": 1,
552
- "client_target_path": 1,
553
- "producer_topic_prefix": 1,
554
- "fqn_suffix": 2,
555
- "route_template": 2,
556
- "short_name": 3,
557
- "client_target": 3,
558
- "producer_topic": 3,
559
- }
560
-
561
- _SYMBOL_RESOLVE_RETURN = (
562
- "s.id AS id, s.fqn AS fqn, s.microservice AS microservice, "
563
- "s.module AS module, s.role AS role, s.kind AS symbol_kind"
564
- )
565
-
566
- _ROUTE_RESOLVE_RETURN = (
567
- "r.id AS id, r.kind AS kind, r.framework AS framework, r.method AS method, "
568
- "r.path AS path, r.path_template AS path_template, r.path_regex AS path_regex, "
569
- "r.topic AS topic, r.broker AS broker, r.feign_name AS feign_name, r.feign_url AS feign_url, "
570
- "r.microservice AS microservice, r.module AS module, r.filename AS filename, "
571
- "r.start_line AS start_line, r.end_line AS end_line, r.resolved AS resolved"
572
- )
572
+ # Re-exported from resolve_service.py (imported at end of module to avoid circular import)
573
+ # resolve_v2, ResolveOutput, ResolveCandidate, ResolveStatus are imported below
573
574
 
574
- _CLIENT_RESOLVE_RETURN = (
575
- "c.id AS id, c.client_kind AS client_kind, c.target_service AS target_service, "
576
- "c.method AS method, c.path AS path, c.path_template AS path_template, "
577
- "c.path_regex AS path_regex, c.member_fqn AS member_fqn, c.member_id AS member_id, "
578
- "c.microservice AS microservice, c.module AS module, c.filename AS filename, "
579
- "c.start_line AS start_line, c.end_line AS end_line, c.resolved AS resolved, "
580
- "c.source_layer AS source_layer"
581
- )
582
575
 
583
- _PRODUCER_RESOLVE_RETURN = (
584
- "p.id AS id, p.producer_kind AS producer_kind, p.topic AS topic, p.broker AS broker, "
585
- "p.direction AS direction, p.member_fqn AS member_fqn, p.member_id AS member_id, "
586
- "p.microservice AS microservice, p.module AS module, p.filename AS filename, "
587
- "p.start_line AS start_line, p.end_line AS end_line, p.resolved AS resolved, "
588
- "p.source_layer AS source_layer"
589
- )
590
-
591
- _RESOLVE_PRE_DEDUP_LIMIT = 50
592
-
593
-
594
- class ResolveCandidate(BaseModel):
595
- model_config = ConfigDict(extra="forbid")
596
-
597
- node: NodeRef
598
- score: float
599
- reason: ResolveReason
600
-
601
-
602
- class ResolveOutput(BaseModel):
603
- model_config = ConfigDict(extra="forbid")
604
-
605
- success: bool
606
- status: ResolveStatus
607
- node: NodeRef | None = None
608
- candidates: list[ResolveCandidate] = Field(default_factory=list)
609
- message: str | None = None
610
- resolved_identifier: str | None = None
611
- advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
612
- hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
613
-
614
-
615
- def _node_kind_from_id(
616
- id_str: str,
617
- ) -> Literal["symbol", "route", "client", "producer", "unresolved_call_site"]:
618
- if id_str.startswith("ucs:"):
619
- return "unresolved_call_site"
620
- if id_str.startswith("sym:"):
621
- return "symbol"
622
- if id_str.startswith("route:") or id_str.startswith("r:"):
623
- return "route"
624
- if id_str.startswith("client:") or id_str.startswith("c:"):
625
- return "client"
626
- if id_str.startswith("producer:") or id_str.startswith("p:"):
627
- return "producer"
628
- raise ValueError(f"Unknown id prefix for `{id_str}`")
629
-
630
-
631
- def _resolve_node_kind(
632
- graph: LadybugGraph,
633
- node_id: str,
634
- ) -> Literal["symbol", "route", "client", "producer", "unresolved_call_site"]:
635
- try:
636
- return _node_kind_from_id(node_id)
637
- except ValueError:
638
- pass
639
- if graph._rows("MATCH (n:Symbol) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
640
- return "symbol"
641
- if graph._rows("MATCH (n:Route) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
642
- return "route"
643
- if graph._rows("MATCH (n:Client) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
644
- return "client"
645
- if graph._rows("MATCH (n:Producer) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
646
- return "producer"
647
- raise ValueError(f"Unknown id prefix for `{node_id}`")
576
+ # _node_kind_from_id and _resolve_node_kind are now defined in graph_types.py and imported above
648
577
 
649
578
 
650
579
  def _chunk_id_from_row(row: dict[str, Any]) -> str:
@@ -656,8 +585,20 @@ def _chunk_id_from_row(row: dict[str, Any]) -> str:
656
585
  return f"{filename}:{sb}:{eb}"
657
586
 
658
587
 
659
- def _row_to_search_hit(row: dict[str, Any]) -> SearchHit:
588
+ def _row_to_search_hit(row: dict[str, Any], explain: bool = False) -> SearchHit:
660
589
  score = float(row.get("_rrf_score") or row.get("_score") or 0.0)
590
+ filename = str(row.get("filename") or "") or None
591
+ start_line: int | None = None
592
+ start = row.get("start")
593
+ if isinstance(start, dict):
594
+ ln = start.get("line")
595
+ if ln is not None:
596
+ try:
597
+ start_line = int(ln)
598
+ except (TypeError, ValueError):
599
+ start_line = None
600
+ chunks = row.get("_chunks_collapsed")
601
+ chunks_int = int(chunks) if chunks is not None and int(chunks) >= 2 else None
661
602
  return SearchHit(
662
603
  chunk_id=_chunk_id_from_row(row),
663
604
  symbol_id=_chunk_to_symbol_id(row),
@@ -667,6 +608,10 @@ def _row_to_search_hit(row: dict[str, Any]) -> SearchHit:
667
608
  microservice=str(row.get("microservice")) if row.get("microservice") else None,
668
609
  module=str(row.get("module")) if row.get("module") else None,
669
610
  role=str(row.get("role")) if row.get("role") else None,
611
+ filename=filename,
612
+ start_line=start_line,
613
+ score_components=row.get("_score_components") if explain else None,
614
+ chunks=chunks_int,
670
615
  )
671
616
 
672
617
 
@@ -710,9 +655,9 @@ def _symbol_where_from_filter(f: NodeFilter) -> tuple[str, dict[str, Any]]:
710
655
  if f.capability:
711
656
  preds.append("$capability IN s.capabilities")
712
657
  params["capability"] = f.capability
713
- if f.fqn_prefix:
714
- preds.append("s.fqn STARTS WITH $fqn_prefix")
715
- params["fqn_prefix"] = f.fqn_prefix
658
+ if f.fqn_contains:
659
+ preds.append("s.fqn CONTAINS $fqn_contains")
660
+ params["fqn_contains"] = f.fqn_contains
716
661
  if f.symbol_kind:
717
662
  preds.append("s.kind = $symbol_kind")
718
663
  params["symbol_kind"] = f.symbol_kind
@@ -723,38 +668,7 @@ def _symbol_where_from_filter(f: NodeFilter) -> tuple[str, dict[str, Any]]:
723
668
  return where, params
724
669
 
725
670
 
726
- def _node_ref_from_row(kind: Literal["symbol", "route", "client", "producer"], row: dict[str, Any]) -> NodeRef:
727
- symbol_kind: str | None = None
728
- if kind == "symbol":
729
- fqn = str(row.get("fqn") or "")
730
- role = str(row.get("role") or "") or None
731
- symbol_kind_val = str(row.get("symbol_kind") or row.get("kind") or "").strip()
732
- symbol_kind = symbol_kind_val or None
733
- elif kind == "route":
734
- method = str(row.get("method") or "")
735
- path = str(row.get("path_template") or row.get("path") or "")
736
- fqn = f"{method} {path}".strip()
737
- role = None
738
- elif kind == "client":
739
- method = str(row.get("method") or "")
740
- target = str(row.get("target_service") or "")
741
- path = str(row.get("path_template") or row.get("path") or "")
742
- fqn = f"{target} {method} {path}".strip()
743
- role = None
744
- else:
745
- topic = str(row.get("topic") or "")
746
- broker = str(row.get("broker") or "")
747
- fqn = f"{topic} {broker}".strip()
748
- role = None
749
- return NodeRef(
750
- id=str(row.get("id") or ""),
751
- kind=kind,
752
- fqn=fqn,
753
- symbol_kind=symbol_kind,
754
- microservice=str(row.get("microservice") or "") or None,
755
- module=str(row.get("module") or "") or None,
756
- role=role,
757
- )
671
+ # _node_ref_from_row is now defined in graph_types.py and imported above
758
672
 
759
673
 
760
674
  def _load_node_record(
@@ -868,7 +782,7 @@ def _node_matches_filter(
868
782
  return False
869
783
  if f.capability and f.capability not in list(row.get("capabilities") or []):
870
784
  return False
871
- if f.fqn_prefix and not fqn_val.startswith(f.fqn_prefix):
785
+ if f.fqn_contains and f.fqn_contains not in fqn_val:
872
786
  return False
873
787
  if f.symbol_kind and symbol_kind_val != f.symbol_kind:
874
788
  return False
@@ -877,9 +791,9 @@ def _node_matches_filter(
877
791
  elif kind == "route":
878
792
  if f.http_method and str(row.get("method") or "") != f.http_method:
879
793
  return False
880
- if f.path_prefix:
794
+ if f.path_contains:
881
795
  path = str(row.get("path") or "")
882
- if not path.startswith(f.path_prefix):
796
+ if f.path_contains not in path:
883
797
  return False
884
798
  if f.framework and str(row.get("framework") or "") != f.framework:
885
799
  return False
@@ -888,18 +802,18 @@ def _node_matches_filter(
888
802
  return False
889
803
  if f.target_service and str(row.get("target_service") or "") != f.target_service:
890
804
  return False
891
- if f.target_path_prefix:
805
+ if f.target_path_contains:
892
806
  path = str(row.get("path") or "")
893
- if not path.startswith(f.target_path_prefix):
807
+ if f.target_path_contains not in path:
894
808
  return False
895
809
  if f.http_method and str(row.get("method") or "") != f.http_method:
896
810
  return False
897
811
  else:
898
812
  if f.producer_kind and str(row.get("producer_kind") or "") != f.producer_kind:
899
813
  return False
900
- if f.topic_prefix:
814
+ if f.topic_contains:
901
815
  topic = str(row.get("topic") or "")
902
- if not topic.startswith(f.topic_prefix):
816
+ if f.topic_contains not in topic:
903
817
  return False
904
818
  return True
905
819
 
@@ -912,7 +826,9 @@ def search_v2(
912
826
  offset: int = 0,
913
827
  path_contains: str | None = None,
914
828
  filter: NodeFilter | dict[str, Any] | str | None = None,
829
+ explain: bool = False,
915
830
  graph: LadybugGraph | None = None,
831
+ dedup: bool = True,
916
832
  ) -> SearchOutput:
917
833
  try:
918
834
  raw_filter = _coerce_filter(filter)
@@ -934,9 +850,27 @@ def search_v2(
934
850
  if nf and (err := _nodefilter_applicability_error("symbol", nf)):
935
851
  _log_fail_loud("applicability")
936
852
  return SearchOutput(success=False, message=err, advisories=[], limit=None, offset=None)
937
- if nf and (err := _validate_no_wildcards(nf)):
938
- _log_fail_loud("wildcard")
939
- return SearchOutput(success=False, message=err, advisories=[], limit=None, offset=None)
853
+ if run_search is None:
854
+ # Graph-only install (no torch/lancedb): the vector stack is absent. Return a
855
+ # clean failure rather than crashing so the server keeps serving graph tools.
856
+ return SearchOutput(
857
+ success=False,
858
+ message="Vector search unavailable: graph-only mode (vector stack not installed).",
859
+ advisories=[],
860
+ limit=None,
861
+ offset=None,
862
+ )
863
+ # hybrid + table='all' is unsupported (hybrid fuses vector+FTS on ONE
864
+ # table); fail fast with a clean envelope BEFORE loading the embedding
865
+ # model. run_search also guards this — this is the user-facing fast path.
866
+ if hybrid and table == "all":
867
+ return SearchOutput(
868
+ success=False,
869
+ message="hybrid search requires a single table; use java, sql, or yaml (not all)",
870
+ advisories=[],
871
+ limit=None,
872
+ offset=None,
873
+ )
940
874
  model_name = resolved_sbert_model_for_process_env(SBERT_MODEL)
941
875
  device = os.environ.get("SBERT_DEVICE") or None
942
876
  model = _get_sentence_transformer(model_name, device)
@@ -947,18 +881,66 @@ def search_v2(
947
881
  if not uri.startswith(("s3://", "gs://", "az://")) and uri_path.exists():
948
882
  uri = str(uri_path.resolve())
949
883
  table_keys = list(TABLES) if table == "all" else [table]
950
- rows = run_search(
951
- query,
952
- uri=uri,
953
- table_keys=table_keys,
954
- hybrid=hybrid,
955
- limit=limit,
956
- offset=offset,
957
- path_substring=path_contains,
958
- model_name=model_name,
959
- device=device,
960
- model=model,
961
- )
884
+
885
+ # Graceful fallback: if hybrid=True and FTS index is missing (old index),
886
+ # retry with hybrid=False and return vector-only results with an advisory.
887
+ advisories: list[str] = []
888
+ try:
889
+ rows = run_search(
890
+ query,
891
+ uri=uri,
892
+ table_keys=table_keys,
893
+ hybrid=hybrid,
894
+ limit=limit,
895
+ offset=offset,
896
+ path_substring=path_contains,
897
+ model_name=model_name,
898
+ device=device,
899
+ model=model,
900
+ # Push the NodeFilter structural predicates into the LanceDB query so
901
+ # they apply BEFORE pagination (issue #353) — previously they were only
902
+ # a post-filter on the already-paginated page, which could shrink or
903
+ # empty filtered pages even when many matches existed deeper in the
904
+ # ranking. _node_matches_filter below still re-checks every row (it
905
+ # covers the non-pushdownable fields and is the contract guarantee).
906
+ role=nf.role if nf else None,
907
+ module=nf.module if nf else None,
908
+ microservice=nf.microservice if nf else None,
909
+ capability=nf.capability if nf else None,
910
+ exclude_roles=nf.exclude_roles if nf else None,
911
+ dedup_by_fqn=dedup,
912
+ )
913
+ except Exception as exc:
914
+ # Check if this is a missing-FTS error (old index built before PR-SEARCH-3)
915
+ exc_text = str(exc).lower()
916
+ is_fts_missing = "full text search" in exc_text or "inverted index" in exc_text
917
+ if hybrid and is_fts_missing:
918
+ # Retry with vector-only search
919
+ rows = run_search(
920
+ query,
921
+ uri=uri,
922
+ table_keys=table_keys,
923
+ hybrid=False, # Fallback to vector-only
924
+ limit=limit,
925
+ offset=offset,
926
+ path_substring=path_contains,
927
+ model_name=model_name,
928
+ device=device,
929
+ model=model,
930
+ role=nf.role if nf else None,
931
+ module=nf.module if nf else None,
932
+ microservice=nf.microservice if nf else None,
933
+ capability=nf.capability if nf else None,
934
+ exclude_roles=nf.exclude_roles if nf else None,
935
+ dedup_by_fqn=dedup,
936
+ )
937
+ advisories.append(
938
+ f"hybrid unavailable on table '{table}' (FTS index missing on this index built before "
939
+ f"PR-SEARCH-3); fell back to vector-only — reindex to enable hybrid"
940
+ )
941
+ else:
942
+ # Non-FTS error: surface as structured failure
943
+ raise
962
944
  hits: list[SearchHit] = []
963
945
  for row in rows:
964
946
  if path_contains and path_contains not in str(row.get("filename") or ""):
@@ -967,7 +949,7 @@ def search_v2(
967
949
  row_kind = "symbol"
968
950
  if not _node_matches_filter(row_kind, row, nf):
969
951
  continue
970
- hits.append(_row_to_search_hit(row))
952
+ hits.append(_row_to_search_hit(row, explain=explain))
971
953
  hint_payload = {
972
954
  "success": True,
973
955
  "results": [h.model_dump() for h in hits],
@@ -980,7 +962,7 @@ def search_v2(
980
962
  results=hits,
981
963
  limit=limit,
982
964
  offset=offset,
983
- advisories=raw_advisories,
965
+ advisories=advisories + raw_advisories, # Merge fallback + hints advisories
984
966
  hints_structured=_to_structured_hints(raw_struct),
985
967
  )
986
968
  except Exception as exc:
@@ -1013,9 +995,6 @@ def find_v2(
1013
995
  if err := _nodefilter_applicability_error(kind, nf):
1014
996
  _log_fail_loud("applicability")
1015
997
  return FindOutput(success=False, message=err, advisories=[], limit=None, offset=None)
1016
- if err := _validate_no_wildcards(nf):
1017
- _log_fail_loud("wildcard")
1018
- return FindOutput(success=False, message=err, advisories=[], limit=None, offset=None)
1019
998
  fetch_cap = int(limit) + int(offset) + 1
1020
999
  if kind == "symbol":
1021
1000
  where, params = _symbol_where_from_filter(nf)
@@ -1029,7 +1008,7 @@ def find_v2(
1029
1008
  rows = g.list_routes(
1030
1009
  microservice=nf.microservice,
1031
1010
  framework=nf.framework,
1032
- path_prefix=nf.path_prefix,
1011
+ path_contains=nf.path_contains,
1033
1012
  method=nf.http_method,
1034
1013
  limit=max(500, fetch_cap),
1035
1014
  )
@@ -1039,7 +1018,7 @@ def find_v2(
1039
1018
  microservice=nf.microservice,
1040
1019
  client_kind=nf.client_kind,
1041
1020
  target_service=nf.target_service,
1042
- path_prefix=nf.target_path_prefix,
1021
+ path_contains=nf.target_path_contains,
1043
1022
  method=nf.http_method,
1044
1023
  limit=max(500, fetch_cap),
1045
1024
  )
@@ -1048,7 +1027,7 @@ def find_v2(
1048
1027
  rows = g.list_producers(
1049
1028
  microservice=nf.microservice,
1050
1029
  producer_kind=nf.producer_kind,
1051
- topic_prefix=nf.topic_prefix,
1030
+ topic_contains=nf.topic_contains,
1052
1031
  limit=max(500, fetch_cap),
1053
1032
  )
1054
1033
  rows = [r for r in rows if _node_matches_filter("producer", r, nf)]
@@ -1059,7 +1038,12 @@ def find_v2(
1059
1038
  hint_payload: dict[str, Any] = {
1060
1039
  "success": True,
1061
1040
  "kind": kind,
1062
- "results": [r.model_dump() for r in refs],
1041
+ # exclude_none: this dict feeds generate_hints (which reads fields
1042
+ # defensively via .get), not the tool result (FindOutput below holds the
1043
+ # pydantic objects). Drop null fields -- including the NodeRef.name field
1044
+ # that is None for every structured ref -- to match filter_dump above and
1045
+ # avoid spurious "name": null noise in the hint input.
1046
+ "results": [r.model_dump(exclude_none=True) for r in refs],
1063
1047
  "limit": limit,
1064
1048
  "offset": offset,
1065
1049
  "filter": filter_dump,
@@ -1071,6 +1055,7 @@ def find_v2(
1071
1055
  results=refs,
1072
1056
  limit=limit,
1073
1057
  offset=offset,
1058
+ has_more_results=has_more_results,
1074
1059
  advisories=raw_advisories,
1075
1060
  hints_structured=_to_structured_hints(raw_struct),
1076
1061
  )
@@ -1159,379 +1144,28 @@ def describe_v2(
1159
1144
  return DescribeOutput(success=False, message=str(exc), advisories=[])
1160
1145
 
1161
1146
 
1162
- def _resolve_validate_identifier(raw: str) -> tuple[str | None, str | None]:
1163
- trimmed = raw.strip()
1164
- if not trimmed:
1165
- detail = "empty string" if raw == "" else "whitespace only"
1166
- return None, f"Invalid identifier: {detail}"
1167
- return trimmed, None
1168
-
1169
1147
 
1170
- def _resolve_kinds_to_search(
1171
- hint_kind: Literal["symbol", "route", "client", "producer"] | None,
1172
- ) -> list[Literal["symbol", "route", "client", "producer"]]:
1173
- if hint_kind is None:
1174
- return ["symbol", "route", "client", "producer"]
1175
- return [hint_kind]
1176
-
1177
-
1178
- def _resolve_parse_route_method_path(identifier: str) -> tuple[str, str] | None:
1179
- parts = identifier.split(None, 1)
1180
- if len(parts) != 2:
1181
- return None
1182
- method, path = parts[0].upper(), parts[1].strip()
1183
- if not method.isalpha() or not path.startswith("/"):
1184
- return None
1185
- return method, path
1186
-
1187
-
1188
- def _resolve_parse_microservice_route(identifier: str) -> tuple[str, str, str] | None:
1189
- parts = identifier.split(None, 2)
1190
- if len(parts) != 3:
1191
- return None
1192
- microservice, method, path = parts[0], parts[1].upper(), parts[2].strip()
1193
- if not method.isalpha() or not path.startswith("/"):
1194
- return None
1195
- return microservice, method, path
1196
1148
 
1197
-
1198
- def _resolve_symbol_candidates(
1199
- g: LadybugGraph,
1200
- identifier: str,
1201
- ) -> list[tuple[NodeRef, ResolveReason, int]]:
1202
- out: list[tuple[NodeRef, ResolveReason, int]] = []
1203
- lim = _RESOLVE_PRE_DEDUP_LIMIT
1204
-
1205
- rows = g._rows( # noqa: SLF001
1206
- f"MATCH (s:Symbol) WHERE s.id = $id RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
1207
- {"id": identifier, "lim": lim},
1208
- )
1209
- for row in rows:
1210
- out.append((_node_ref_from_row("symbol", row), "exact_id", len(identifier)))
1211
-
1212
- rows = g._rows( # noqa: SLF001
1213
- f"MATCH (s:Symbol) WHERE s.fqn = $fqn RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
1214
- {"fqn": identifier, "lim": lim},
1215
- )
1216
- for row in rows:
1217
- out.append((_node_ref_from_row("symbol", row), "exact_fqn", len(identifier)))
1218
-
1219
- suffix = f".{identifier}"
1220
- rows = g._rows( # noqa: SLF001
1221
- f"MATCH (s:Symbol) WHERE s.fqn = $ident OR s.fqn ENDS WITH $suffix "
1222
- f"RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
1223
- {"ident": identifier, "suffix": suffix, "lim": lim},
1224
- )
1225
- for row in rows:
1226
- fqn = str(row.get("fqn") or "")
1227
- spec = len(fqn)
1228
- out.append((_node_ref_from_row("symbol", row), "fqn_suffix", spec))
1229
-
1230
- rows = g._rows( # noqa: SLF001
1231
- f"MATCH (s:Symbol) WHERE s.name = $name RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
1232
- {"name": identifier, "lim": lim},
1233
- )
1234
- for row in rows:
1235
- out.append((_node_ref_from_row("symbol", row), "short_name", len(identifier)))
1236
-
1237
- return out
1238
-
1239
-
1240
- def _resolve_route_candidates(
1241
- g: LadybugGraph,
1242
- identifier: str,
1243
- ) -> list[tuple[NodeRef, ResolveReason, int]]:
1244
- out: list[tuple[NodeRef, ResolveReason, int]] = []
1245
- lim = _RESOLVE_PRE_DEDUP_LIMIT
1246
-
1247
- rows = g._rows( # noqa: SLF001
1248
- f"MATCH (r:Route) WHERE r.id = $id RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
1249
- {"id": identifier, "lim": lim},
1250
- )
1251
- for row in rows:
1252
- out.append((_node_ref_from_row("route", row), "exact_id", len(identifier)))
1253
-
1254
- ms_route = _resolve_parse_microservice_route(identifier)
1255
- if ms_route is not None:
1256
- microservice, method, path = ms_route
1257
- rows = g._rows( # noqa: SLF001
1258
- f"MATCH (r:Route) WHERE r.microservice = $ms AND r.method = $method "
1259
- f"AND (r.path = $path OR r.path_template = $path) "
1260
- f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
1261
- {"ms": microservice, "method": method, "path": path, "lim": lim},
1262
- )
1263
- for row in rows:
1264
- spec = len(path)
1265
- out.append((_node_ref_from_row("route", row), "route_method_path", spec))
1266
-
1267
- method_path = _resolve_parse_route_method_path(identifier)
1268
- if method_path is not None:
1269
- method, path = method_path
1270
- rows = g._rows( # noqa: SLF001
1271
- f"MATCH (r:Route) WHERE r.method = $method "
1272
- f"AND (r.path = $path OR r.path_template = $path) "
1273
- f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
1274
- {"method": method, "path": path, "lim": lim},
1275
- )
1276
- for row in rows:
1277
- out.append((_node_ref_from_row("route", row), "route_method_path", len(path)))
1278
-
1279
- if identifier.startswith("/"):
1280
- rows = g._rows( # noqa: SLF001
1281
- f"MATCH (r:Route) WHERE r.path = $path OR r.path_template = $path "
1282
- f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
1283
- {"path": identifier, "lim": lim},
1284
- )
1285
- for row in rows:
1286
- path_val = str(row.get("path_template") or row.get("path") or "")
1287
- out.append((_node_ref_from_row("route", row), "route_template", len(path_val)))
1288
-
1289
- return out
1290
-
1291
-
1292
- def _resolve_client_candidates(
1293
- g: LadybugGraph,
1294
- identifier: str,
1295
- ) -> list[tuple[NodeRef, ResolveReason, int]]:
1296
- out: list[tuple[NodeRef, ResolveReason, int]] = []
1297
- lim = _RESOLVE_PRE_DEDUP_LIMIT
1298
-
1299
- rows = g._rows( # noqa: SLF001
1300
- f"MATCH (c:Client) WHERE c.id = $id RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
1301
- {"id": identifier, "lim": lim},
1302
- )
1303
- for row in rows:
1304
- out.append((_node_ref_from_row("client", row), "exact_id", len(identifier)))
1305
-
1306
- if " " in identifier:
1307
- target, path_prefix = identifier.split(" ", 1)
1308
- target = target.strip()
1309
- path_prefix = path_prefix.strip()
1310
- if target and path_prefix:
1311
- rows = g._rows( # noqa: SLF001
1312
- f"MATCH (c:Client) WHERE c.target_service = $target "
1313
- f"AND (c.path STARTS WITH $path OR c.path_template STARTS WITH $path) "
1314
- f"RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
1315
- {"target": target, "path": path_prefix, "lim": lim},
1316
- )
1317
- for row in rows:
1318
- spec = len(path_prefix)
1319
- out.append((_node_ref_from_row("client", row), "client_target_path", spec))
1320
- elif not identifier.startswith("/"):
1321
- rows = g._rows( # noqa: SLF001
1322
- f"MATCH (c:Client) WHERE c.target_service = $target RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
1323
- {"target": identifier, "lim": lim},
1324
- )
1325
- for row in rows:
1326
- out.append((_node_ref_from_row("client", row), "client_target", len(identifier)))
1327
-
1328
- return out
1329
-
1330
-
1331
- def _resolve_producer_candidates(
1332
- g: LadybugGraph,
1333
- identifier: str,
1334
- ) -> list[tuple[NodeRef, ResolveReason, int]]:
1335
- out: list[tuple[NodeRef, ResolveReason, int]] = []
1336
- lim = _RESOLVE_PRE_DEDUP_LIMIT
1337
-
1338
- rows = g._rows( # noqa: SLF001
1339
- f"MATCH (p:Producer) WHERE p.id = $id RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
1340
- {"id": identifier, "lim": lim},
1341
- )
1342
- for row in rows:
1343
- out.append((_node_ref_from_row("producer", row), "exact_id", len(identifier)))
1344
-
1345
- rows = g._rows( # noqa: SLF001
1346
- f"MATCH (p:Producer) WHERE p.topic = $topic RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
1347
- {"topic": identifier, "lim": lim},
1348
- )
1349
- for row in rows:
1350
- out.append((_node_ref_from_row("producer", row), "producer_topic", len(identifier)))
1351
-
1352
- if not identifier.startswith("/"):
1353
- rows = g._rows( # noqa: SLF001
1354
- f"MATCH (p:Producer) WHERE p.topic STARTS WITH $topic RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
1355
- {"topic": identifier, "lim": lim},
1356
- )
1357
- for row in rows:
1358
- out.append((_node_ref_from_row("producer", row), "producer_topic_prefix", len(identifier)))
1359
-
1360
- return out
1361
-
1362
-
1363
- def _resolve_dedupe_candidates(
1364
- raw: list[tuple[NodeRef, ResolveReason, int]],
1365
- ) -> list[tuple[NodeRef, ResolveReason, int]]:
1366
- best: dict[str, tuple[NodeRef, ResolveReason, int]] = {}
1367
- for node, reason, specificity in raw:
1368
- prev = best.get(node.id)
1369
- if prev is None:
1370
- best[node.id] = (node, reason, specificity)
1371
- continue
1372
- prev_pri = _RESOLVE_REASON_PRIORITY[prev[1]]
1373
- new_pri = _RESOLVE_REASON_PRIORITY[reason]
1374
- if new_pri < prev_pri or (new_pri == prev_pri and specificity > prev[2]):
1375
- best[node.id] = (node, reason, specificity)
1376
- return list(best.values())
1377
-
1378
-
1379
- def _resolve_rank_candidates(
1380
- deduped: list[tuple[NodeRef, ResolveReason, int]],
1381
- ) -> list[ResolveCandidate]:
1382
- ordered = sorted(
1383
- deduped,
1384
- key=lambda item: (_RESOLVE_REASON_PRIORITY[item[1]], -item[2], item[0].id),
1385
- )
1386
- total = len(ordered)
1387
- return [
1388
- ResolveCandidate(
1389
- node=node,
1390
- reason=reason,
1391
- score=(1.0 - (idx / total)) if total else 0.0,
1392
- )
1393
- for idx, (node, reason, _spec) in enumerate(ordered)
1394
- ]
1395
-
1396
-
1397
- def _resolve_assert_invariants(out: ResolveOutput) -> None:
1398
- if not out.success:
1399
- assert out.status == "none"
1400
- assert out.node is None
1401
- assert not out.candidates
1402
- assert out.message
1403
- return
1404
- if out.status == "one":
1405
- assert out.node is not None
1406
- assert not out.candidates
1407
- elif out.status == "many":
1408
- assert out.node is None
1409
- assert len(out.candidates) >= 2
1410
- elif out.status == "none":
1411
- assert out.node is None
1412
- assert not out.candidates
1413
- assert out.message
1414
-
1415
-
1416
- def _resolve_seeds_for_hints(identifier: str) -> tuple[str | None, str | None]:
1417
- path_prefix_seed: str | None = None
1418
- method_path = _resolve_parse_route_method_path(identifier)
1419
- if method_path is not None:
1420
- path_prefix_seed = method_path[1]
1421
- else:
1422
- ms_route = _resolve_parse_microservice_route(identifier)
1423
- if ms_route is not None:
1424
- path_prefix_seed = ms_route[2]
1425
- elif identifier.startswith("/"):
1426
- path_prefix_seed = identifier
1427
-
1428
- target_service_seed: str | None = None
1429
- if " " in identifier:
1430
- target, _path_prefix = identifier.split(" ", 1)
1431
- target = target.strip()
1432
- if target:
1433
- target_service_seed = target
1434
- elif not identifier.startswith("/"):
1435
- target_service_seed = identifier
1436
-
1437
- return path_prefix_seed, target_service_seed
1438
-
1439
-
1440
- def _resolve_finalize_success(
1441
- trimmed: str,
1442
- hint_kind: Literal["symbol", "route", "client", "producer"] | None,
1443
- matches: list[ResolveCandidate],
1444
- ) -> ResolveOutput:
1445
- if not matches:
1446
- out = ResolveOutput(
1447
- success=True,
1448
- status="none",
1449
- message=(
1450
- "No matches for identifier; use search(query=...) for ranked fuzzy lookup."
1451
- ),
1452
- resolved_identifier=trimmed,
1453
- )
1454
- elif len(matches) == 1:
1455
- out = ResolveOutput(
1456
- success=True,
1457
- status="one",
1458
- node=matches[0].node,
1459
- resolved_identifier=trimmed,
1460
- )
1461
- else:
1462
- out = ResolveOutput(
1463
- success=True,
1464
- status="many",
1465
- candidates=matches,
1466
- resolved_identifier=trimmed,
1467
- )
1468
-
1469
- path_prefix_seed, target_service_seed = _resolve_seeds_for_hints(trimmed)
1470
- hint_payload = {
1471
- "status": out.status,
1472
- "resolved_identifier": trimmed,
1473
- "candidates": out.candidates,
1474
- "hint_kind": hint_kind,
1475
- "path_prefix_seed": path_prefix_seed,
1476
- "target_service_seed": target_service_seed,
1477
- }
1478
- raw_struct, raw_advisories = _hints_or_skip("resolve", hint_payload)
1479
- out = out.model_copy(update={
1480
- "advisories": raw_advisories,
1481
- "hints_structured": _to_structured_hints(raw_struct),
1482
- })
1483
- _resolve_assert_invariants(out)
1484
- return out
1485
-
1486
-
1487
- def resolve_v2(
1488
- identifier: str,
1489
- hint_kind: Literal["symbol", "route", "client", "producer"] | None = None,
1490
- graph: LadybugGraph | None = None,
1491
- ) -> ResolveOutput:
1492
- try:
1493
- trimmed, err = _resolve_validate_identifier(identifier)
1494
- if err is not None:
1495
- out = ResolveOutput(
1496
- success=False,
1497
- status="none",
1498
- message=err,
1499
- advisories=[],
1500
- resolved_identifier=None,
1501
- )
1502
- _resolve_assert_invariants(out)
1503
- return out
1504
-
1505
- assert trimmed is not None
1506
- if "*" in trimmed or "?" in trimmed:
1507
- return _resolve_finalize_success(trimmed, hint_kind, [])
1508
-
1509
- g = graph or LadybugGraph.get()
1510
- raw: list[tuple[NodeRef, ResolveReason, int]] = []
1511
- for kind in _resolve_kinds_to_search(hint_kind):
1512
- if kind == "symbol":
1513
- raw.extend(_resolve_symbol_candidates(g, trimmed))
1514
- elif kind == "route":
1515
- raw.extend(_resolve_route_candidates(g, trimmed))
1516
- elif kind == "client":
1517
- raw.extend(_resolve_client_candidates(g, trimmed))
1518
- else:
1519
- raw.extend(_resolve_producer_candidates(g, trimmed))
1520
-
1521
- deduped = _resolve_dedupe_candidates(raw)
1522
- ranked = _resolve_rank_candidates(deduped)
1523
- capped = ranked[:_RESOLVE_CANDIDATE_CAP]
1524
- return _resolve_finalize_success(trimmed, hint_kind, capped)
1525
- except Exception as exc:
1526
- out = ResolveOutput(
1527
- success=False,
1528
- status="none",
1529
- message=str(exc),
1530
- advisories=[],
1531
- resolved_identifier=None,
1532
- )
1533
- _resolve_assert_invariants(out)
1534
- return out
1149
+ # Per-edge-type attribute columns selected by the generic (flat-label) neighbors
1150
+ # query (issue #356). RETURNing a fixed superset of columns regardless of which
1151
+ # edge type matched is the typed-union RETURN anti-pattern: a stricter binder
1152
+ # (e.g. Kùzu) errors when a RETURNed column does not exist on the matched type.
1153
+ # Selecting columns per edge type keeps the query portable; _neighbor_edge_attrs
1154
+ # still drops None/"" so each edge exposes only the attrs that exist for its type.
1155
+ # Aligned with the REL TABLE schemas in build_ast_graph.py.
1156
+ _FLAT_EDGE_ATTR_COLUMNS: dict[str, tuple[str, ...]] = {
1157
+ "CALLS": ("confidence", "strategy", "source", "call_site_line", "call_site_byte", "arg_count", "resolved"),
1158
+ "HTTP_CALLS": ("confidence", "strategy", "match"),
1159
+ "ASYNC_CALLS": ("confidence", "strategy", "match"),
1160
+ "EXPOSES": ("confidence", "strategy"),
1161
+ "DECLARES_CLIENT": ("confidence", "strategy"),
1162
+ "DECLARES_PRODUCER": ("confidence", "strategy"),
1163
+ "INJECTS": ("mechanism", "annotation", "field_or_param", "resolved"),
1164
+ "EXTENDS": ("resolved",),
1165
+ "IMPLEMENTS": ("resolved",),
1166
+ "DECLARES": (),
1167
+ "OVERRIDES": (),
1168
+ }
1535
1169
 
1536
1170
 
1537
1171
  def _neighbor_edge_attrs(row: dict[str, Any]) -> dict[str, Any]:
@@ -1813,9 +1447,6 @@ def neighbors_v2(
1813
1447
  message=err,
1814
1448
  requested_edge_types=requested_edge_types,
1815
1449
  )
1816
- if nf and (err := _validate_no_wildcards(nf)):
1817
- _log_fail_loud("wildcard")
1818
- return NeighborsOutput(success=False, message=err, requested_edge_types=[])
1819
1450
  if composed_keys and direction != "out":
1820
1451
  return NeighborsOutput(
1821
1452
  success=False,
@@ -1896,33 +1527,25 @@ def neighbors_v2(
1896
1527
  results.extend(origin_edges)
1897
1528
  continue
1898
1529
  if flat_labels:
1899
- # Some Cypher binders can drop `label(e) IN $list` in WHERE; use OR of scalar equalities.
1900
- label_params = [f"l{i}" for i in range(len(flat_labels))]
1901
- label_predicate = "(" + " OR ".join(f"label(e) = ${name}" for name in label_params) + ")"
1902
- q_params = {"id": origin_id, **dict(zip(label_params, flat_labels, strict=True))}
1903
- if direction == "out":
1904
- rows = g._rows( # noqa: SLF001
1905
- "MATCH (a)-[e]->(b) WHERE a.id = $id AND "
1906
- f"{label_predicate} "
1907
- "RETURN b.id AS other_id, label(e) AS edge_type, e.confidence AS confidence, "
1908
- "e.strategy AS strategy, e.match AS match, e.mechanism AS mechanism, "
1909
- "e.annotation AS annotation, e.field_or_param AS field_or_param, "
1910
- "e.source AS source, e.call_site_line AS call_site_line, "
1911
- "e.call_site_byte AS call_site_byte, e.arg_count AS arg_count, "
1912
- "e.resolved AS resolved",
1913
- q_params,
1914
- )
1915
- else:
1916
- rows = g._rows( # noqa: SLF001
1917
- "MATCH (a)<-[e]-(b) WHERE a.id = $id AND "
1918
- f"{label_predicate} "
1919
- "RETURN b.id AS other_id, label(e) AS edge_type, e.confidence AS confidence, "
1920
- "e.strategy AS strategy, e.match AS match, e.mechanism AS mechanism, "
1921
- "e.annotation AS annotation, e.field_or_param AS field_or_param, "
1922
- "e.source AS source, e.call_site_line AS call_site_line, "
1923
- "e.call_site_byte AS call_site_byte, e.arg_count AS arg_count, "
1924
- "e.resolved AS resolved",
1925
- q_params,
1530
+ # Select attribute columns per edge type (issue #356). A single
1531
+ # multi-label query RETURNing a fixed column superset references
1532
+ # columns that don't exist on every matched type — the typed-union
1533
+ # RETURN anti-pattern, which errors on stricter binders (e.g. Kùzu).
1534
+ # Run one single-label query per type, RETURNing only that type's
1535
+ # columns, and merge the rows. `label(e) = $label` scalar equality
1536
+ # (not `label(e) IN [...]`) per the AGENTS.md Cypher note.
1537
+ rows: list[dict[str, Any]] = []
1538
+ match_clause = "MATCH (a)-[e]->(b)" if direction == "out" else "MATCH (a)<-[e]-(b)"
1539
+ for label in flat_labels:
1540
+ cols = _FLAT_EDGE_ATTR_COLUMNS.get(label, ())
1541
+ select = "b.id AS other_id, label(e) AS edge_type"
1542
+ if cols:
1543
+ select += ", " + ", ".join(f"e.{c} AS {c}" for c in cols)
1544
+ rows.extend(
1545
+ g._rows( # noqa: SLF001
1546
+ f"{match_clause} WHERE a.id = $id AND label(e) = $label RETURN {select}",
1547
+ {"id": origin_id, "label": label},
1548
+ )
1926
1549
  )
1927
1550
  for row in rows:
1928
1551
  other_id = str(row.get("other_id") or "")
@@ -1979,14 +1602,25 @@ def neighbors_v2(
1979
1602
  )
1980
1603
  if use_calls_path and len(origins) > 1:
1981
1604
  sliced = results[offset : offset + limit]
1605
+ neighbors_has_more = len(results) > offset + limit
1606
+ elif use_calls_path:
1607
+ # Single-origin CALLS path. When paginate_in_sql is True the SQL did
1608
+ # the OFFSET/LIMIT and the row/unfiltered counts carry the has-more
1609
+ # signal, so this field stays None (unknown). When paginate_in_sql is
1610
+ # False (a node_filter is set, include_unresolved, or dedup_calls) we
1611
+ # loaded the FULL matching set with no pushdown, so the client already
1612
+ # has every edge -> False (not None), so a paging client need not probe.
1613
+ sliced = results
1614
+ neighbors_has_more = None if paginate_in_sql else False
1982
1615
  else:
1983
- sliced = results if use_calls_path else results[offset : offset + limit]
1616
+ sliced = results[offset : offset + limit]
1617
+ neighbors_has_more = len(results) > offset + limit
1984
1618
  first_origin = origins[0]
1985
1619
  origin_kind = _resolve_node_kind(g, first_origin)
1986
1620
  subject_record = _load_node_record(g, first_origin, origin_kind)
1987
1621
  neigh_payload = {
1988
1622
  "success": True,
1989
- "results": [e.model_dump() for e in sliced],
1623
+ "results": [e.model_dump(exclude_none=True) for e in sliced],
1990
1624
  "requested_edge_types": requested_edge_types,
1991
1625
  "requested_direction": direction,
1992
1626
  "offset": offset,
@@ -2006,6 +1640,7 @@ def neighbors_v2(
2006
1640
  success=True,
2007
1641
  results=sliced,
2008
1642
  requested_edge_types=requested_edge_types,
1643
+ has_more_results=neighbors_has_more,
2009
1644
  advisories=raw_advisories,
2010
1645
  hints_structured=_to_structured_hints(raw_struct),
2011
1646
  )
@@ -2013,3 +1648,12 @@ def neighbors_v2(
2013
1648
  raise
2014
1649
  except Exception as exc:
2015
1650
  return NeighborsOutput(success=False, message=str(exc), advisories=[], requested_edge_types=[])
1651
+
1652
+
1653
+ # Re-export resolve symbols from resolve_service.py (imported here to avoid circular import)
1654
+ from resolve_service import ( # noqa: E402
1655
+ ResolveCandidate,
1656
+ ResolveOutput,
1657
+ ResolveStatus,
1658
+ resolve_v2,
1659
+ )