diff --git a/README.md b/README.md index ae2f4bf..b0551db 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ Edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/ | `find` | Locate nodes by structured filter. | `kind: "symbol"\|"route"\|"client"`, `filter: NodeFilter \| str`, `limit: int=25`, `offset: int=0` | `{"kind":"symbol","filter":{"role":"CONTROLLER"}}` | | `describe` | Full record + edge counts for one node. For **type** symbols, `edge_summary` may include composed dot-keys (`DECLARES.DECLARES_CLIENT`, `DECLARES.EXPOSES`); for **method** symbols it may include override-axis virtual keys (`OVERRIDDEN_BY`, …) and an `OVERRIDES` row that **merges** stored `[:OVERRIDES]` in/out with the dispatch-up rollup (per direction `max`). See [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) (`describe`). | `id: str` | `{"id":"sym:com.bank.chat.core.api.ChatController#joinOperator(JoinOperatorRequest)"}` | | `resolve` | Identifier-shaped node lookup (symbol / route / client). Returns `status` `one`, `many`, or `none`; prefer over `describe(fqn=…)` when an FQN may collide. See [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) (`resolve`). | `identifier: str`, `hint_kind: "symbol"|"route"|"client" \| null` | `{"identifier":"com.bank.chat.core.api.ChatController","hint_kind":"symbol"}` | -| `neighbors` | One-hop walk. **Required**: `direction` and `edge_types`. | `ids: str \| list[str]`, `direction: "in"\|"out"`, `edge_types: list[str]`, `limit: int=25`, `offset: int=0`, `filter: NodeFilter \| str \| None` | `{"ids":"route:chat-core:POST:/chat/joinOperator","direction":"in","edge_types":["HTTP_CALLS","ASYNC_CALLS"]}` | +| `neighbors` | Graph walk. **Required**: `direction` and `edge_types` (stored labels; type Symbols may also pass composed `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` — `out` only — see [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md)). | `ids: str \| list[str]`, `direction: "in"\|"out"`, `edge_types: list[str]`, `limit: int=25`, `offset: int=0`, `filter: NodeFilter \| str \| None` | `{"ids":"sym:…ChatController","direction":"out","edge_types":["DECLARES.DECLARES_CLIENT"]}` | **`NodeFilter` notes:** diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index c30bf2f..5c5296b 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -225,16 +225,16 @@ Exact allowed values for roles, capabilities, client kinds, etc. live in `java_o #### `describe` -- **Purpose:** Full node payload + `edge_summary`: `in` / `out` counts **per stored graph edge label** (what exists as edges in Kuzu). For **type** Symbols only (`class`, `interface`, `enum`, `record`, `annotation`), the same map may also include **describe-time composed** dot-keys — summaries of member edges, not stored labels — see the next bullets (`DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES`); those keys are **not** valid in `neighbors(edge_types=…)`. For **method** Symbols, the map may include **override-axis virtual keys** (`OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES`) plus an **`OVERRIDES` row** that merges stored `[:OVERRIDES]` incident counts with the describe-time dispatch-up rollup (per direction `max`, so inbound stored overrides are preserved); see **Override-axis keys (method Symbols)** below — those virtual keys are **not** `neighbors` arguments. The **stored** relationship label **`OVERRIDES`** **is** a valid `EdgeType` for `neighbors` (same spelling as the map key; the map row is the merged view). +- **Purpose:** Full node payload + `edge_summary`: `in` / `out` counts **per stored graph edge label** (what exists as edges in Kuzu). For **type** Symbols only (`class`, `interface`, `enum`, `record`, `annotation`), the same map may also include **describe-time composed** dot-keys — summaries of member edges, not stored labels — see the next bullets (`DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES`); those three keys **are** valid in `neighbors(edge_types=…)` for type Symbol origins (`direction="out"` only). For **method** Symbols, the map may include **override-axis virtual keys** (`OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES`) plus an **`OVERRIDES` row** that merges stored `[:OVERRIDES]` incident counts with the describe-time dispatch-up rollup (per direction `max`, so inbound stored overrides are preserved); see **Override-axis keys (method Symbols)** below — those virtual keys are **not** `neighbors` arguments. The **stored** relationship label **`OVERRIDES`** **is** a valid `EdgeType` for `neighbors` (same spelling as the map key; the map row is the merged view). - **Args:** `id` (symbol, route, or client id) or **`fqn`** (exact symbol FQN when you do not have the graph id). When both are set, `id` wins. For identifier-shaped inputs and FQN collision handling, see **Identifier resolution** above. **Composed `edge_summary` keys (type Symbols).** Keys use dot notation: `.`. Three are emitted today: -- `DECLARES.DECLARES_CLIENT` — the type's methods declare brownfield HTTP clients (count is the number of `Client` rows reached through `DECLARES → DECLARES_CLIENT`). To enumerate them: `neighbors(ids=, direction="out", edge_types=["DECLARES"])` → for each method id, `neighbors(ids=, direction="out", edge_types=["DECLARES_CLIENT"])`. -- `DECLARES.DECLARES_PRODUCER` — the type's methods declare async producers. Same walk shape with `DECLARES_PRODUCER`. -- `DECLARES.EXPOSES` — the type's methods expose routes. Same walk shape with `EXPOSES`. +- `DECLARES.DECLARES_CLIENT` — the type's methods declare brownfield HTTP clients (count is the number of `Client` rows reached through `DECLARES → DECLARES_CLIENT`). Primary recipe: `neighbors(ids=, direction="out", edge_types=["DECLARES.DECLARES_CLIENT"])` → terminal `Client` nodes with `via_id` in `attrs`. Alternative (two hops): `neighbors(..., ["DECLARES"])` then per-method `DECLARES_CLIENT`. +- `DECLARES.DECLARES_PRODUCER` — producers via members. Use `edge_types=["DECLARES.DECLARES_PRODUCER"]` (or the two-hop `DECLARES` → `DECLARES_PRODUCER` walk). +- `DECLARES.EXPOSES` — routes via members. Use `edge_types=["DECLARES.EXPOSES"]` (or the two-hop `DECLARES` → `EXPOSES` walk). -Composed keys are **read-only**: they cannot be passed to `neighbors(edge_types=…)` (the dot is not a valid `EdgeType` literal — the call fails with a Pydantic `ValidationError`). Use them as a hop affordance only. +These three `DECLARES.*` dot-keys are navigable on **type** Symbol origins (`out` only). `OVERRIDDEN_BY*` virtual keys remain describe-only. Note on counting semantics: composed counts measure **edge rows**, not distinct member methods. One method that declares multiple `Client` rows (e.g. a `rest_template` method with several call sites) contributes its full edge count to `DECLARES.DECLARES_CLIENT`. The "does this class have any clients?" predicate is answered by `count > 0`; the count itself is an affordance for how rich the downstream walk will be. @@ -248,7 +248,7 @@ Walk recipe (manual, if you need types in the middle): `neighbors(ids= list[str]: """Normalize Kuzu ``collect(DISTINCT ...)`` list results to string ids.""" @@ -629,11 +637,7 @@ def member_edge_rollup_for(self, type_id: str) -> dict[str, dict[str, int]]: """ params = {"id": type_id} rollup: dict[str, dict[str, int]] = {} - for key, rel in ( - ("DECLARES.DECLARES_CLIENT", "DECLARES_CLIENT"), - ("DECLARES.DECLARES_PRODUCER", "DECLARES_PRODUCER"), - ("DECLARES.EXPOSES", "EXPOSES"), - ): + for key, rel in _MEMBER_EDGE_COMPOSED_REL_MAP: rows = self._rows( f"MATCH (t:Symbol {{id: $id}})-[:DECLARES]->(m:Symbol)-[e:{rel}]->() " "RETURN count(e) AS n", @@ -644,6 +648,25 @@ def member_edge_rollup_for(self, type_id: str) -> dict[str, dict[str, int]]: rollup[key] = {"in": 0, "out": n} return rollup + def member_edge_traversal_for(self, type_id: str, composed_key: str) -> list[dict[str, Any]]: + """2-hop DECLARES member traversal for a type Symbol (neighbors dot-key path).""" + rel = _MEMBER_EDGE_COMPOSED_REL_BY_KEY.get(composed_key) + if rel is None: + return [] + # Untyped [e] + label(e) filter: typed unions fail the binder when RETURN references + # columns that exist on only some rel types (same pattern as flat neighbors_v2). + return self._rows( + "MATCH (t:Symbol {id: $id})-[:DECLARES]->(m:Symbol)-[e]->(term) " + "WHERE label(e) = $rel " + "RETURN m.id AS via_id, label(e) AS stored_edge_type, " + "term.id AS other_id, e.confidence AS confidence, e.strategy AS strategy, " + "e.match AS match, e.mechanism AS mechanism, e.annotation AS annotation, " + "e.field_or_param AS field_or_param, e.source AS source, " + "e.call_site_line AS call_site_line, e.call_site_byte AS call_site_byte, " + "e.arg_count AS arg_count, e.resolved AS resolved", + {"id": type_id, "rel": rel}, + ) + def _edge_row_count_from_method_ids(self, method_ids: list[str], rel: str) -> int: """Count outgoing ``rel`` edges from method symbols (describe rollup helper).""" total = 0 diff --git a/mcp_hints.py b/mcp_hints.py index dd0fb20..265492c 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -17,24 +17,22 @@ MCP_HINTS_FIELD_DESCRIPTION = ( "Road-sign hints pointing to likely next calls. Each hint is a short string " "referencing one MCP V2 tool call. Hints are advisory and may be safely ignored. " - "Maximum 5 hints per output. Hints never recommend dot-key edge labels (composed " - "rollups) as neighbors() arguments. For neighbors with multiple origin ids, " - "empty-result structural hints describe the first origin only." + "Maximum 5 hints per output. Describe-time type rollup hints may recommend " + "DECLARES.* dot-keys for neighbors(); empty neighbors structural hints never use " + "dot-key edge labels. For neighbors with multiple origin ids, empty-result " + "structural hints describe the first origin only." ) # --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) --- TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS = ( - "clients via members: neighbors(['{id}'],'out',['DECLARES']) " - "then neighbors(member_ids,'out',['DECLARES_CLIENT'])" + "clients via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_CLIENT'])" ) TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS = ( - "routes via members: neighbors(['{id}'],'out',['DECLARES']) " - "then neighbors(member_ids,'out',['EXPOSES'])" + "routes via members: neighbors(['{id}'],'out',['DECLARES.EXPOSES'])" ) TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS = ( - "producers via members: neighbors(['{id}'],'out',['DECLARES']) " - "then neighbors(member_ids,'out',['DECLARES_PRODUCER'])" + "producers via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_PRODUCER'])" ) TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'in',['OVERRIDES'])" TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS = ( diff --git a/mcp_v2.py b/mcp_v2.py index 5f7518c..8df97f8 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -22,7 +22,7 @@ import sys from pathlib import Path import threading -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Literal, get_args from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, validate_call from sentence_transformers import SentenceTransformer @@ -36,9 +36,9 @@ DeclarationSymbolKind = Literal["class", "interface", "enum", "record", "annotation", "method", "constructor"] -# Composed describe-time keys in edge_summary (e.g. DECLARES.DECLARES_CLIENT) are -# intentionally not EdgeType literals — neighbors(edge_types=...) rejects them. -# Virtual override-axis keys (OVERRIDDEN_BY, …) are also rejected; stored OVERRIDES is an EdgeType. +# Stored graph edge labels for one-hop neighbors. Composed DECLARES.* dot-keys are +# separate ComposedEdgeType literals (2-hop traversal). Virtual override-axis keys +# (OVERRIDDEN_BY, …) are rejected by _NEIGHBOR_EDGE_TYPES_ADAPTER; stored OVERRIDES is an EdgeType. EdgeType = Literal[ "EXTENDS", "IMPLEMENTS", @@ -53,8 +53,21 @@ "ASYNC_CALLS", ] +ComposedEdgeType = Literal[ + "DECLARES.DECLARES_CLIENT", + "DECLARES.DECLARES_PRODUCER", + "DECLARES.EXPOSES", +] + +NeighborEdgeType = EdgeType | ComposedEdgeType + +_COMPOSED_EDGE_TYPES = frozenset(get_args(ComposedEdgeType)) + _NEIGHBOR_EDGE_TYPES_ADAPTER = TypeAdapter( - Annotated[list[EdgeType], Field(min_length=1, description="At least one graph edge label")] + Annotated[ + list[NeighborEdgeType], + Field(min_length=1, description="At least one graph edge label or DECLARES.* dot-key"), + ] ) _st_lock = threading.Lock() @@ -278,14 +291,14 @@ class NodeRecord(BaseModel): "Per graph edge label, in/out incident counts. For type Symbols (class, interface, " "enum, record, annotation), may also include composed dot-keys " "`DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, and `DECLARES.EXPOSES`: 2-hop summaries " - "(DECLARES to member, then that edge) — edge-row counts, not EdgeType literals; " - "do not pass them to neighbors(edge_types=…). For method Symbols, may include " + "(DECLARES to member, then that edge) — edge-row counts; navigable via neighbors for type " + "Symbol origins (`direction=\"out\"` only). For method Symbols, may include " "override-axis virtual keys `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, " "`OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES`, plus an `OVERRIDES` map entry " "that **merges** stored " "`[:OVERRIDES]` in/out counts with the describe-time dispatch-up rollup (per " - "direction `max`, so inbound stored overrides are not dropped). Those virtual / " - "dot-keys are not valid neighbors(edge_types=…) arguments. The stored relationship " + "direction `max`, so inbound stored overrides are not dropped). Those OVERRIDDEN_BY* " + "virtual keys are not valid neighbors(edge_types=…) arguments. The stored relationship " "label `OVERRIDES` **is** a valid EdgeType for neighbors." ), ) @@ -1292,26 +1305,32 @@ def resolve_v2( return out +def _neighbor_edge_attrs(row: dict[str, Any]) -> dict[str, Any]: + return { + k: v + for k, v in row.items() + if k not in {"other_id", "edge_type", "stored_edge_type"} + and v not in (None, "") + } + + @validate_call(config={"arbitrary_types_allowed": True}) def neighbors_v2( ids: str | list[str], # Required fields are intentional: direct Python calls and MCP-bound calls # share the same validation contract through @validate_call. direction: Literal["in", "out"] = Field(...), - edge_types: list[EdgeType] = Field(...), + edge_types: list[NeighborEdgeType] = Field(...), limit: int = 25, offset: int = 0, filter: NodeFilter | dict[str, Any] | str | None = None, graph: Any | None = None, ) -> NeighborsOutput: try: - _NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python(edge_types) - # Kuzu 0.11.x can drop `label(e) IN $list` in WHERE; use OR of scalar equalities instead. - # Typed unions like `[e:CALLS|HTTP_CALLS]` fail the binder when RETURN references rel - # columns that exist on only some of the union members. - labels = list(dict.fromkeys(edge_types)) - label_params = [f"l{i}" for i in range(len(labels))] - label_predicate = "(" + " OR ".join(f"label(e) = ${name}" for name in label_params) + ")" + validated_types = _NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python(edge_types) + requested_edge_types = list(dict.fromkeys(validated_types)) + flat_labels = [et for et in requested_edge_types if et not in _COMPOSED_EDGE_TYPES] + composed_keys = [et for et in requested_edge_types if et in _COMPOSED_EDGE_TYPES] g = graph or KuzuGraph.get() try: raw_filter = _coerce_filter(filter) @@ -1331,75 +1350,120 @@ def neighbors_v2( if nf and (err := _validate_no_wildcards(nf)): _log_fail_loud("wildcard") return NeighborsOutput(success=False, message=err, hints=[], requested_edge_types=[]) + if composed_keys and direction != "out": + return NeighborsOutput( + success=False, + message='Composed edge types require direction="out"', + hints=[], + requested_edge_types=requested_edge_types, + ) origins = [ids] if isinstance(ids, str) else list(ids) results: list[Edge] = [] for origin_id in origins: - _resolve_node_kind(g, origin_id) - q_params = {"id": origin_id, **dict(zip(label_params, labels, strict=True))} - if direction == "out": - rows = g._rows( # noqa: SLF001 - "MATCH (a)-[e]->(b) WHERE a.id = $id AND " - f"{label_predicate} " - "RETURN b.id AS other_id, label(e) AS edge_type, e.confidence AS confidence, " - "e.strategy AS strategy, e.match AS match, e.mechanism AS mechanism, " - "e.annotation AS annotation, e.field_or_param AS field_or_param, " - "e.source AS source, e.call_site_line AS call_site_line, " - "e.call_site_byte AS call_site_byte, e.arg_count AS arg_count, " - "e.resolved AS resolved", - q_params, - ) - else: - rows = g._rows( # noqa: SLF001 - "MATCH (a)<-[e]-(b) WHERE a.id = $id AND " - f"{label_predicate} " - "RETURN b.id AS other_id, label(e) AS edge_type, e.confidence AS confidence, " - "e.strategy AS strategy, e.match AS match, e.mechanism AS mechanism, " - "e.annotation AS annotation, e.field_or_param AS field_or_param, " - "e.source AS source, e.call_site_line AS call_site_line, " - "e.call_site_byte AS call_site_byte, e.arg_count AS arg_count, " - "e.resolved AS resolved", - q_params, - ) - for row in rows: - other_id = str(row.get("other_id") or "") - other_kind = _resolve_node_kind(g, other_id) - other_rec = _load_node_record(g, other_id, other_kind) - if other_rec is None: - continue - if nf and (err := _nodefilter_applicability_error(other_kind, nf)): - _log_fail_loud("applicability") - return NeighborsOutput(success=False, message=err, hints=[], requested_edge_types=[]) - if not _node_matches_filter(other_kind, other_rec, nf): - continue - attrs = { - k: v - for k, v in row.items() - if k - not in { - "other_id", - "edge_type", - } - and v not in (None, "") - } - results.append( - Edge( - origin_id=origin_id, - edge_type=str(row.get("edge_type") or ""), - direction=direction, - other=_node_ref_from_row(other_kind, other_rec), - attrs=attrs, + origin_kind = _resolve_node_kind(g, origin_id) + if composed_keys: + if origin_kind != "symbol": + return NeighborsOutput( + success=False, + message=( + f"Composed edge types ({composed_keys[0]}) require a type Symbol origin" + ), + hints=[], + requested_edge_types=requested_edge_types, + ) + origin_row = _load_node_record(g, origin_id, "symbol") + sym_kind = str((origin_row or {}).get("kind") or "") + if sym_kind not in _TYPE_SYMBOL_KINDS_FOR_EDGE_ROLLUP: + return NeighborsOutput( + success=False, + message=( + f"Composed edge types ({composed_keys[0]}) require a type Symbol origin" + ), + hints=[], + requested_edge_types=requested_edge_types, + ) + if flat_labels: + # Kuzu 0.11.x can drop `label(e) IN $list` in WHERE; use OR of scalar equalities. + label_params = [f"l{i}" for i in range(len(flat_labels))] + label_predicate = "(" + " OR ".join(f"label(e) = ${name}" for name in label_params) + ")" + q_params = {"id": origin_id, **dict(zip(label_params, flat_labels, strict=True))} + if direction == "out": + rows = g._rows( # noqa: SLF001 + "MATCH (a)-[e]->(b) WHERE a.id = $id AND " + f"{label_predicate} " + "RETURN b.id AS other_id, label(e) AS edge_type, e.confidence AS confidence, " + "e.strategy AS strategy, e.match AS match, e.mechanism AS mechanism, " + "e.annotation AS annotation, e.field_or_param AS field_or_param, " + "e.source AS source, e.call_site_line AS call_site_line, " + "e.call_site_byte AS call_site_byte, e.arg_count AS arg_count, " + "e.resolved AS resolved", + q_params, + ) + else: + rows = g._rows( # noqa: SLF001 + "MATCH (a)<-[e]-(b) WHERE a.id = $id AND " + f"{label_predicate} " + "RETURN b.id AS other_id, label(e) AS edge_type, e.confidence AS confidence, " + "e.strategy AS strategy, e.match AS match, e.mechanism AS mechanism, " + "e.annotation AS annotation, e.field_or_param AS field_or_param, " + "e.source AS source, e.call_site_line AS call_site_line, " + "e.call_site_byte AS call_site_byte, e.arg_count AS arg_count, " + "e.resolved AS resolved", + q_params, + ) + for row in rows: + other_id = str(row.get("other_id") or "") + other_kind = _resolve_node_kind(g, other_id) + other_rec = _load_node_record(g, other_id, other_kind) + if other_rec is None: + continue + if nf and (err := _nodefilter_applicability_error(other_kind, nf)): + _log_fail_loud("applicability") + return NeighborsOutput( + success=False, message=err, hints=[], requested_edge_types=[] + ) + if not _node_matches_filter(other_kind, other_rec, nf): + continue + results.append( + Edge( + origin_id=origin_id, + edge_type=str(row.get("edge_type") or ""), + direction=direction, + other=_node_ref_from_row(other_kind, other_rec), + attrs=_neighbor_edge_attrs(row), + ) + ) + for composed_key in composed_keys: + for row in g.member_edge_traversal_for(origin_id, composed_key): + other_id = str(row.get("other_id") or "") + other_kind = _resolve_node_kind(g, other_id) + other_rec = _load_node_record(g, other_id, other_kind) + if other_rec is None: + continue + if nf and (err := _nodefilter_applicability_error(other_kind, nf)): + _log_fail_loud("applicability") + return NeighborsOutput( + success=False, message=err, hints=[], requested_edge_types=[] + ) + if not _node_matches_filter(other_kind, other_rec, nf): + continue + results.append( + Edge( + origin_id=origin_id, + edge_type=composed_key, + direction="out", + other=_node_ref_from_row(other_kind, other_rec), + attrs=_neighbor_edge_attrs(row), + ) ) - ) sliced = results[offset : offset + limit] first_origin = origins[0] origin_kind = _resolve_node_kind(g, first_origin) subject_record = _load_node_record(g, first_origin, origin_kind) - # Empty-result hints use the sliced page only; offset>0 or strict filters can - # yield [] while hops exist — skip structural hints in that case. neigh_payload = { "success": True, "results": [e.model_dump() for e in sliced], - "requested_edge_types": list(labels), + "requested_edge_types": requested_edge_types, "requested_direction": direction, "offset": offset, "origin_id": first_origin, @@ -1408,7 +1472,7 @@ def neighbors_v2( return NeighborsOutput( success=True, results=sliced, - requested_edge_types=list(labels), + requested_edge_types=requested_edge_types, hints=generate_hints("neighbors", neigh_payload), ) except ValidationError: diff --git a/plans/CURSOR-PROMPTS-NEIGHBORS-DOT-KEY-TRAVERSAL.md b/plans/completed/CURSOR-PROMPTS-NEIGHBORS-DOT-KEY-TRAVERSAL.md similarity index 93% rename from plans/CURSOR-PROMPTS-NEIGHBORS-DOT-KEY-TRAVERSAL.md rename to plans/completed/CURSOR-PROMPTS-NEIGHBORS-DOT-KEY-TRAVERSAL.md index 1453a6e..4a0e179 100644 --- a/plans/CURSOR-PROMPTS-NEIGHBORS-DOT-KEY-TRAVERSAL.md +++ b/plans/completed/CURSOR-PROMPTS-NEIGHBORS-DOT-KEY-TRAVERSAL.md @@ -1,8 +1,8 @@ # Cursor task prompts — NEIGHBORS-DOT-KEY-TRAVERSAL -Status: **active (planning)**. Plan: -[`plans/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md`](./PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md). Propose: -[`propose/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md`](../propose/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md). +Status: **completed** (landed [#171](https://github.com/HumanBean17/java-codebase-rag/pull/171)). Plan: +[`plans/completed/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md`](./PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md). Propose: +[`propose/completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md`](../../propose/completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md). **Depends on:** none. @@ -26,8 +26,8 @@ One prompt: **PR-1** (single implementation PR). **Attach (`@-files`):** -- `@plans/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md` -- `@propose/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md` +- `@plans/completed/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md` +- `@propose/completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md` - `@kuzu_queries.py` (`member_edge_rollup_for` — mirror for traversal) - `@mcp_v2.py` (`neighbors_v2`, `EdgeType`, `NodeRecord.edge_summary`, `_TYPE_SYMBOL_KINDS_FOR_EDGE_ROLLUP`) - `@mcp_hints.py` (`TPL_DESCRIBE_TYPE_*`, `MCP_HINTS_FIELD_DESCRIPTION`, `_filter_neighbors_dotkey_hints`) diff --git a/plans/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md b/plans/completed/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md similarity index 93% rename from plans/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md rename to plans/completed/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md index 95eadd9..34dc3c3 100644 --- a/plans/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md +++ b/plans/completed/PLAN-NEIGHBORS-DOT-KEY-TRAVERSAL.md @@ -1,7 +1,7 @@ # Plan: NEIGHBORS-DOT-KEY-TRAVERSAL -Status: **active (planning)**. This plan implements -[`propose/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md`](../propose/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md) +Status: **completed** (PR-1 landed in [#171](https://github.com/HumanBean17/java-codebase-rag/pull/171)). Source propose: +[`propose/completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md`](../../propose/completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md) (issue [#162](https://github.com/HumanBean17/java-codebase-rag/issues/162)). Depends on: **none** (query-time / MCP surface only; graph schema unchanged). @@ -178,13 +178,13 @@ Implement every name in the table above plus hint tests. Regression: ## Definition of done (PR-1) -- [ ] `neighbors_v2` accepts the three `DECLARES.*` dot-keys; flat edge types unchanged. -- [ ] Composed results: dot-key `edge_type`, terminal `other`, `via_id` in `attrs`, full attr projection aligned with flat hops. -- [ ] Type-only origin + `out` only enforced with `success=False` messages. -- [ ] `OVERRIDDEN_BY` / `OVERRIDDEN_BY.*` still rejected at validation. -- [ ] `edge_summary` field description and agent docs match behavior. -- [ ] Three describe hint templates prescribe dot-key calls; `MCP_HINTS_FIELD_DESCRIPTION` updated. -- [ ] All named tests green; `ruff` + full `pytest tests` (non-heavy) pass. +- [x] `neighbors_v2` accepts the three `DECLARES.*` dot-keys; flat edge types unchanged. +- [x] Composed results: dot-key `edge_type`, terminal `other`, `via_id` in `attrs`, full attr projection aligned with flat hops. +- [x] Type-only origin + `out` only enforced with `success=False` messages. +- [x] `OVERRIDDEN_BY` / `OVERRIDDEN_BY.*` still rejected at validation. +- [x] `edge_summary` field description and agent docs match behavior. +- [x] Three describe hint templates prescribe dot-key calls; `MCP_HINTS_FIELD_DESCRIPTION` updated. +- [x] All named tests green; `ruff` + full `pytest tests` (non-heavy) pass. ## Implementation step list @@ -218,14 +218,14 @@ Implement every name in the table above plus hint tests. Regression: - `SearchHit` / `find` schema changes - HINTS-V4 success-path catalog (`propose/HINTS-V4-SUCCESS-PATH-PROPOSE.md`) - Ontology version bump / graph builder / re-index -- Moving propose to `propose/completed/` (do when PR merges) # Whole-plan done definition -1. PR-1 merged; propose moved to `propose/completed/` with `Status: landed`. +1. PR-1 merged; propose in `propose/completed/` with `Status: landed`. 2. Issue #162 acceptance criteria met for `DECLARES.*` family (not #165 / #167). 3. `pytest tests` and `ruff check .` pass on `master` without heavy gate. # Tracking -- `PR-1`: _pending_ +- `PR-1`: landed ([#171](https://github.com/HumanBean17/java-codebase-rag/pull/171)) +- Follow-up: [#172](https://github.com/HumanBean17/java-codebase-rag/issues/172) — single source of truth for composed dot-keys diff --git a/propose/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md b/propose/completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md similarity index 97% rename from propose/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md rename to propose/completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md index 7619dc4..c475e87 100644 --- a/propose/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md +++ b/propose/completed/NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE.md @@ -2,7 +2,7 @@ ## Status -Proposal — not yet implemented. +**Landed** — PR [#171](https://github.com/HumanBean17/java-codebase-rag/pull/171) (PR-1). Addresses [#162](https://github.com/HumanBean17/java-codebase-rag/issues/162) (partially — see [Limitations](#limitations)). @@ -10,7 +10,7 @@ Addresses [#162](https://github.com/HumanBean17/java-codebase-rag/issues/162) ## Decision reversal This proposal **deliberately reverses decision #11** from -[`DESCRIBE-MEMBER-EDGE-ROLLUP-PROPOSE`](./completed/DESCRIBE-MEMBER-EDGE-ROLLUP-PROPOSE.md) +[`DESCRIBE-MEMBER-EDGE-ROLLUP-PROPOSE`](./DESCRIBE-MEMBER-EDGE-ROLLUP-PROPOSE.md) (PR-89), which made composed dot-keys read-only by construction — Pydantic `EdgeType` rejected them at the type-system level, and `AGENT-GUIDE.md` documented them as hop affordances only. @@ -290,6 +290,8 @@ Single implementation PR. Touches `mcp_v2.py` (model + handler), test files. Follow-up issues: +- [#172](https://github.com/HumanBean17/java-codebase-rag/issues/172) — + single source of truth for composed dot-keys (`ComposedEdgeType` drift) - [#165](https://github.com/HumanBean17/java-codebase-rag/issues/165) — `OVERRIDDEN_BY.*` dot-key support - [#167](https://github.com/HumanBean17/java-codebase-rag/issues/167) — diff --git a/scripts/generate_edge_navigation.py b/scripts/generate_edge_navigation.py index a9f3965..3fa695e 100644 --- a/scripts/generate_edge_navigation.py +++ b/scripts/generate_edge_navigation.py @@ -10,7 +10,13 @@ if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) -from java_ontology import EDGE_SCHEMA, EdgeSpec # noqa: E402 +from java_ontology import ( # noqa: E402 + EDGE_SCHEMA, + EdgeSpec, + _COMPOSED_MEMBER_TYPE_TRAVERSAL, +) + +_COMPOSED_MEMBER_EDGE_NAMES = frozenset({"EXPOSES", "DECLARES_CLIENT", "DECLARES_PRODUCER"}) _DEFAULT_OUT = _REPO_ROOT / "docs" / "EDGE-NAVIGATION.md" _BANNER = ( @@ -49,6 +55,11 @@ def _render_edge(spec: EdgeSpec) -> list[str]: lines.append("**Typical traversals**:") lines.append("") for role, traversal in spec.typical_traversals.items(): + if role == "type_subject" and spec.name in _COMPOSED_MEMBER_EDGE_NAMES: + # _COMPOSED_MEMBER_TYPE_TRAVERSAL already includes the two-hop alternative. + traversal = _COMPOSED_MEMBER_TYPE_TRAVERSAL.format( + id="{id}", direction="{direction}", edge=spec.name, + ) lines.append(f"- `{role}`: {traversal}") lines.append("") return lines diff --git a/server.py b/server.py index 1d1d364..f86a724 100644 --- a/server.py +++ b/server.py @@ -30,7 +30,8 @@ "resolve (identifier-shaped lookup for symbol/route/client/producer — three statuses one|many|none). " "NodeFilter `filter` is a JSON object (preferred); a JSON-encoded string is also accepted as a fallback. " "Unknown filter keys and populated fields not applicable to the effective node kind fail with success=false and message. " - "Edge labels: EXTENDS, IMPLEMENTS, INJECTS, OVERRIDES, DECLARES, DECLARES_CLIENT, DECLARES_PRODUCER, CALLS, EXPOSES, HTTP_CALLS, ASYNC_CALLS. " + "Edge labels: EXTENDS, IMPLEMENTS, INJECTS, OVERRIDES, DECLARES, DECLARES_CLIENT, DECLARES_PRODUCER, CALLS, EXPOSES, HTTP_CALLS, ASYNC_CALLS; " + "type Symbols may also use composed neighbors edge_types DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, DECLARES.EXPOSES (out only). " "Reprocess/init, meta, tables, diagnose-ignore, analyze-pr: use java-codebase-rag CLI — not MCP." ) @@ -416,10 +417,10 @@ async def find( name="describe", description=( "Full node record plus `edge_summary` (in/out counts per stored edge label, plus optional describe-time keys). Type Symbols may add " - "composed keys DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, and DECLARES.EXPOSES; method Symbols may add " - "override-axis virtual keys (OVERRIDDEN_BY, OVERRIDDEN_BY.DECLARES_CLIENT, OVERRIDDEN_BY.DECLARES_PRODUCER, OVERRIDDEN_BY.EXPOSES, " - "plus an `OVERRIDES` map entry that merges stored `[:OVERRIDES]` counts with the dispatch-up rollup per direction). Those dot-keys and virtual keys are " - "read-only summaries—not valid `neighbors(edge_types=…)` values. The stored `OVERRIDES` relationship " + "composed keys DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, and DECLARES.EXPOSES (navigable on type Symbols via neighbors, out only); " + "method Symbols may add override-axis virtual keys (OVERRIDDEN_BY, OVERRIDDEN_BY.DECLARES_CLIENT, OVERRIDDEN_BY.DECLARES_PRODUCER, " + "OVERRIDDEN_BY.EXPOSES, plus an `OVERRIDES` map entry that merges stored `[:OVERRIDES]` counts with the dispatch-up rollup per direction). " + "OVERRIDDEN_BY* virtual keys are not valid `neighbors(edge_types=…)` values. The stored `OVERRIDES` relationship " "is a normal edge label and may be traversed via neighbors(edge_types=[..., \"OVERRIDES\", ...]). " "Pass `id` for any kind, or exact `fqn` for Symbol lookup (`id` wins when both are set). " "`describe(fqn=…)` keeps the first graph row when multiple symbols share that FQN; when an FQN may collide, " @@ -448,7 +449,9 @@ async def describe( @mcp.tool( name="neighbors", description=( - "One-hop graph walk: **direction** (`in` | `out`) and non-empty **edge_types** are required. " + "Graph walk: **direction** (`in` | `out`) and non-empty **edge_types** are required (stored labels for one hop; " + "type Symbol origins may also pass composed DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, or DECLARES.EXPOSES " + "for 2-hop member rollups — out only, with via_id in attrs). OVERRIDDEN_BY* keys are not valid edge_types. " "Optional `filter` applies to each neighbor endpoint row; populated fields must be applicable to that " "neighbor's kind—mixed-kind result sets fail on the first inapplicable neighbor (strict frame). " "Wildcards in prefix fields are rejected. Unknown NodeFilter keys return success=false. " @@ -464,8 +467,12 @@ async def neighbors( direction: Literal["in", "out"] = Field( description="Required. 'in' = predecessors (callers), 'out' = successors (callees). No default.", ), - edge_types: list[mcp_v2.EdgeType] = Field( - description="Required non-empty list of edge labels (e.g. CALLS, EXPOSES, HTTP_CALLS, OVERRIDES)", + edge_types: list[mcp_v2.NeighborEdgeType] = Field( + description=( + "Required non-empty list of stored edge labels (e.g. CALLS, EXPOSES, HTTP_CALLS, OVERRIDES) " + "and/or composed DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, DECLARES.EXPOSES " + "(type Symbol origin, direction out only)" + ), ), limit: int = Field( default=25, diff --git a/tests/test_mcp_v2.py b/tests/test_mcp_v2.py index d6685d6..9b4a3a4 100644 --- a/tests/test_mcp_v2.py +++ b/tests/test_mcp_v2.py @@ -542,17 +542,6 @@ def test_neighbors_invalid_edge_type_rejected(kuzu_graph) -> None: neighbors_v2(mid, direction="in", edge_types=["calls"], graph=kuzu_graph) -def test_neighbors_rejects_composed_edge_summary_key(kuzu_graph) -> None: - mid = _method_id_with_calls(kuzu_graph, "out") - with pytest.raises(ValidationError): - neighbors_v2( - mid, - direction="out", - edge_types=["DECLARES.DECLARES_CLIENT"], - graph=kuzu_graph, - ) - - async def test_find_invalid_kind_rejected(mcp_server) -> None: with pytest.raises(ToolError, match="Input should be"): await mcp_server.call_tool("find", {"kind": "method", "filter": {}}) diff --git a/tests/test_mcp_v2_compose.py b/tests/test_mcp_v2_compose.py index 542f252..874d9d5 100644 --- a/tests/test_mcp_v2_compose.py +++ b/tests/test_mcp_v2_compose.py @@ -560,12 +560,113 @@ def test_neighbors_edge_type_adapter_accepts_overrides() -> None: _NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python(["OVERRIDES"]) -def test_neighbors_rejects_overridden_by_and_dot_keys(kuzu_graph: KuzuGraph) -> None: +def test_neighbors_still_rejects_overridden_by(kuzu_graph: KuzuGraph) -> None: node_id, _ = _controller_method_with_calls(kuzu_graph) with pytest.raises(ValidationError): neighbors_v2(node_id, direction="out", edge_types=["OVERRIDDEN_BY"], graph=kuzu_graph) with pytest.raises(ValidationError): - neighbors_v2(node_id, direction="out", edge_types=["DECLARES.DECLARES_CLIENT"], graph=kuzu_graph) + neighbors_v2( + node_id, direction="out", edge_types=["OVERRIDDEN_BY.DECLARES_CLIENT"], graph=kuzu_graph + ) + + +def _type_id_with_composed_key(kuzu_graph: KuzuGraph, rel: str, composed_key: str) -> tuple[str, int]: + rows = kuzu_graph._rows( # noqa: SLF001 + f"MATCH (t:Symbol)-[:DECLARES]->(m:Symbol)-[e:{rel}]->() " + "WHERE t.kind IN $kinds " + "RETURN t.id AS id, count(e) AS n ORDER BY n DESC LIMIT 1", + {"kinds": _ROLLUP_TYPE_KINDS}, + ) + assert rows, f"no type with {composed_key} in fixture" + return str(rows[0]["id"]), int(rows[0]["n"] or 0) + + +def test_neighbors_declares_dot_key_client(kuzu_graph: KuzuGraph) -> None: + tid, _ = _type_id_with_composed_key(kuzu_graph, "DECLARES_CLIENT", "DECLARES.DECLARES_CLIENT") + out = neighbors_v2(tid, direction="out", edge_types=["DECLARES.DECLARES_CLIENT"], graph=kuzu_graph, limit=500) + assert out.success is True + assert len(out.results) >= 1 + assert all(e.edge_type == "DECLARES.DECLARES_CLIENT" for e in out.results) + assert all(e.attrs.get("via_id") for e in out.results) + assert all(e.other.kind == "client" for e in out.results) + + +def test_neighbors_declares_dot_key_producer(kuzu_graph: KuzuGraph) -> None: + tid, _ = _type_id_with_composed_key( + kuzu_graph, "DECLARES_PRODUCER", "DECLARES.DECLARES_PRODUCER" + ) + out = neighbors_v2( + tid, direction="out", edge_types=["DECLARES.DECLARES_PRODUCER"], graph=kuzu_graph, limit=500 + ) + assert out.success is True + assert len(out.results) >= 1 + assert all(e.edge_type == "DECLARES.DECLARES_PRODUCER" for e in out.results) + assert all(e.attrs.get("via_id") for e in out.results) + assert all(e.other.kind == "producer" for e in out.results) + + +def test_neighbors_declares_dot_key_exposes(kuzu_graph: KuzuGraph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol)-[:DECLARES]->(m:Symbol)-[e:EXPOSES]->(:Route) " + "WHERE t.role = 'CONTROLLER' AND t.kind = 'class' " + "RETURN t.id AS id, count(e) AS n ORDER BY n DESC LIMIT 1", + ) + assert rows + tid = str(rows[0]["id"]) + out = neighbors_v2(tid, direction="out", edge_types=["DECLARES.EXPOSES"], graph=kuzu_graph, limit=500) + assert out.success is True + assert len(out.results) >= 1 + assert all(e.edge_type == "DECLARES.EXPOSES" for e in out.results) + assert all(e.attrs.get("via_id") for e in out.results) + assert all(e.other.kind == "route" for e in out.results) + + +def test_neighbors_dot_key_mixed_with_flat(kuzu_graph: KuzuGraph) -> None: + tid, _ = _type_id_with_composed_key(kuzu_graph, "DECLARES_CLIENT", "DECLARES.DECLARES_CLIENT") + out = neighbors_v2( + tid, + direction="out", + edge_types=["DECLARES", "DECLARES.DECLARES_CLIENT"], + graph=kuzu_graph, + limit=500, + ) + assert out.success is True + edge_types_seen = {e.edge_type for e in out.results} + assert "DECLARES" in edge_types_seen + assert "DECLARES.DECLARES_CLIENT" in edge_types_seen + assert any(e.other.kind == "symbol" for e in out.results) + assert any(e.other.kind == "client" for e in out.results) + + +def test_neighbors_dot_key_inbound_rejected(kuzu_graph: KuzuGraph) -> None: + tid, _ = _type_id_with_composed_key(kuzu_graph, "DECLARES_CLIENT", "DECLARES.DECLARES_CLIENT") + out = neighbors_v2(tid, direction="in", edge_types=["DECLARES.DECLARES_CLIENT"], graph=kuzu_graph) + assert out.success is False + assert out.message is not None + assert 'direction="out"' in out.message + + +def test_neighbors_dot_key_method_origin_rejected(kuzu_graph: KuzuGraph) -> None: + node_id, _ = _controller_method_with_calls(kuzu_graph) + out = neighbors_v2( + node_id, direction="out", edge_types=["DECLARES.DECLARES_CLIENT"], graph=kuzu_graph + ) + assert out.success is False + assert out.message is not None + assert "type Symbol origin" in out.message + + +def test_neighbors_dot_key_count_matches_edge_summary(kuzu_graph: KuzuGraph) -> None: + tid, _ = _type_id_with_composed_key(kuzu_graph, "DECLARES_CLIENT", "DECLARES.DECLARES_CLIENT") + d = describe_v2(tid, graph=kuzu_graph) + n = neighbors_v2( + tid, direction="out", edge_types=["DECLARES.DECLARES_CLIENT"], graph=kuzu_graph, limit=500 + ) + assert d.success and d.record and d.record.edge_summary + summary = d.record.edge_summary.get("DECLARES.DECLARES_CLIENT") + assert summary is not None + assert n.success is True + assert len(n.results) == summary["out"] def test_overrides_edge_set_deterministic_double_build(tmp_path: Path) -> None: