From 3a9712afa242b4ce80f3fae4b646c232d87caf15 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 20 May 2026 11:12:10 +0300 Subject: [PATCH 1/2] feat(neighbors): navigate OVERRIDDEN_BY.* composed edge types in one call Wire override-axis dot-keys through stored [:OVERRIDES] dispatch hops so describe edge_summary counts match neighbors traversal (merge-blocking parity). Co-authored-by: Cursor --- README.md | 4 +- docs/AGENT-GUIDE.md | 20 ++- docs/EDGE-NAVIGATION.md | 13 ++ kuzu_queries.py | 37 ++++ mcp_hints.py | 14 +- mcp_v2.py | 89 +++++++--- scripts/generate_edge_navigation.py | 16 ++ server.py | 13 +- tests/test_mcp_hints.py | 9 + tests/test_mcp_v2_compose.py | 253 ++++++++++++++++++++++++++-- 10 files changed, 417 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index ab17a3a..d498fac 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"\|"producer"`, `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 / producer). 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"|"producer" \| null` | `{"identifier":"com.bank.chat.core.api.ChatController","hint_kind":"symbol"}` | -| `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`, `edge_filter: EdgeFilter \| str \| None` (`CALLS` only; see guide) | `{"ids":"sym:…ChatController","direction":"out","edge_types":["DECLARES.DECLARES_CLIENT"]}` | +| `neighbors` | Graph walk. **Required**: `direction` and `edge_types` (stored labels; type Symbols may pass composed `DECLARES.*`; non-static method Symbols may pass `OVERRIDDEN_BY*` — `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`, `edge_filter: EdgeFilter \| str \| None` (`CALLS` only; see guide) | `{"ids":"sym:…ChatController","direction":"out","edge_types":["DECLARES.DECLARES_CLIENT"]}` | **`NodeFilter` notes:** @@ -431,7 +431,7 @@ Ontology **15** (CALLS-NOISE) adds `CALLS.callee_declaring_role`, `GraphMeta.pas Ontology **14** introduces `EDGE_SCHEMA` in `java_ontology.py` as the canonical edge navigation schema (see `docs/EDGE-NAVIGATION.md`). **`HTTP_CALLS` is `Client → Route`** (SCHEMA-V2 PR-B). **`ASYNC_CALLS` is `Producer → Route`** with `DECLARES_PRODUCER` (SCHEMA-V2 PR-C). Run one full reprocess after upgrading through the SCHEMA-V2 sequence (or when you need the v14 ontology gate). -Ontology **13** materializes stored `OVERRIDES` edges between method Symbols (subtype override → supertype declaration, matching `signature` on a direct `IMPLEMENTS` / `EXTENDS` hop). `neighbors(edge_types=["OVERRIDES"])` traverses this relationship; `OVERRIDDEN_BY*` keys in `edge_summary` remain describe-time rollups only. +Ontology **13** materializes stored `OVERRIDES` edges between method Symbols (subtype override → supertype declaration, matching `signature` on a direct `IMPLEMENTS` / `EXTENDS` hop). `neighbors(edge_types=["OVERRIDES"])` traverses this relationship; `OVERRIDDEN_BY*` dot-keys in `edge_summary` are also navigable on method Symbol origins (`out` only). Ontology **12** renames `@CodebaseClient` to `@CodebaseHttpClient`, types HTTP `method` as the shared `CodebaseHttpMethod` enum on both inbound and outbound stubs, and makes inbound layer-C HTTP routes **replace** same-method built-in Spring rows (no merge). Rebuild after upgrading so `meta_chain` keys and annotation simple names match the extractor. diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 23f4b7c..cd52b97 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -76,7 +76,7 @@ Use these strings **verbatim** in `neighbors(..., edge_types=[...])`. | Service boundary | `EXPOSES` | method Symbol → Route (handler exposes route) | | Cross-service | `HTTP_CALLS`, `ASYNC_CALLS` | `HTTP_CALLS`: Client → Route; `ASYNC_CALLS`: Producer → Route | -**Composed edges (type Symbol origin, `direction="out"` only):** +**Composed edges — type Symbol origin (`direction="out"` only):** | Edge type | Meaning | | --------- | ------- | @@ -84,7 +84,18 @@ Use these strings **verbatim** in `neighbors(..., edge_types=[...])`. | `DECLARES.DECLARES_PRODUCER` | Members' async producers in one hop | | `DECLARES.EXPOSES` | Members' exposed routes in one hop | -**Not valid in `edge_types`:** `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES` (describe-only virtual keys). +**Composed edges — non-static method Symbol origin (`direction="out"` only):** + +| Edge type | Meaning | +| --------- | ------- | +| `OVERRIDDEN_BY` | Concrete overrider methods (stored `[:OVERRIDES]` dispatch hop) | +| `OVERRIDDEN_BY.DECLARES_CLIENT` | Clients declared on overriders (`via_id` = overrider method) | +| `OVERRIDDEN_BY.DECLARES_PRODUCER` | Producers on overriders | +| `OVERRIDDEN_BY.EXPOSES` | Routes exposed by overriders | + +**Stored vs virtual direction (base override axis):** `neighbors(decl_id, "out", ["OVERRIDDEN_BY"])` returns the same overrider method ids as `neighbors(decl_id, "in", ["OVERRIDES"])` on the same declaration method. Prefer the dot-key when `describe.edge_summary` advertises `OVERRIDDEN_BY`. + +Do not mix `DECLARES.*` and `OVERRIDDEN_BY.*` in one `edge_types` list on a single origin id — the handler rejects the whole request (only one axis applies per node). ### Argument shapes @@ -201,7 +212,7 @@ Full node + `edge_summary`. Args: `id` (any kind) or `fqn` (symbol only; `id` wi - **Stored keys** — counts for edges that exist in the graph. - **Type symbols** (`class`, `interface`, `enum`, `record`, `annotation`) may add composed keys `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` — navigable via `neighbors` with those dot-keys (`out` only). -- **Method symbols** may add virtual keys `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_*`, `OVERRIDDEN_BY.EXPOSES` (describe only), plus an **`OVERRIDES`** row merging stored `[:OVERRIDES]` counts with a dispatch-up rollup (`in`/`out` per direction uses `max` of stored vs rollup). Use `neighbors(..., ["OVERRIDES"])` to list override edges. Static methods and constructors do not get override-axis keys. +- **Method symbols** may add virtual keys `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_*`, `OVERRIDDEN_BY.EXPOSES` (navigable via `neighbors` on method origins, `out` only), plus an **`OVERRIDES`** row merging stored `[:OVERRIDES]` counts with a dispatch-up rollup (`in`/`out` per direction uses `max` of stored vs rollup). Use `neighbors(..., ["OVERRIDDEN_BY.DECLARES_CLIENT"])` (etc.) or one-hop `neighbors(..., ["OVERRIDES"])`. Static methods and constructors do not get override-axis keys. Composed counts are **edge rows**, not distinct methods; `count > 0` means "there is something to walk". @@ -249,7 +260,8 @@ Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, … on cross- | Empty `search` | Wrong `table`, no index, or chunk miss | Try `table="all"`; `find` with `fqn_prefix`; read source files directly | | Empty results across several tools | Index missing, stale, or wrong project | You cannot rebuild the index via MCP — ask the operator; meanwhile use open files / `rg` | | Result vs open file disagree | Stale or partial index | Trust the file; say index may be stale | -| Used virtual key in `neighbors` | `OVERRIDDEN_BY*` is describe-only | Use stored `OVERRIDES` or manual walk via `DECLARES` → type → `IMPLEMENTS`/`EXTENDS` | +| Mixed composed families on one id | `DECLARES.*` + `OVERRIDDEN_BY.*` together | Split calls — type keys need a type id; override keys need a method id | +| Override dot-key on type / DECLARES on method | Wrong Symbol origin for axis | Read `describe.edge_summary`; use the axis that matches the node kind | After two failed attempts on the same intent, stop and report tool name, args, and response snippet. diff --git a/docs/EDGE-NAVIGATION.md b/docs/EDGE-NAVIGATION.md index 9e79055..d71bc1d 100644 --- a/docs/EDGE-NAVIGATION.md +++ b/docs/EDGE-NAVIGATION.md @@ -254,6 +254,19 @@ - `alien_subject`: ASYNC_CALLS connects Producer→Route; use DECLARES_PRODUCER from a method Symbol, or neighbors(producer_id,'out',['ASYNC_CALLS']) from a Producer id +## Override-axis composed keys (method Symbol, `direction="out"` only) + +Virtual `edge_summary` / `neighbors` dot-keys (not stored graph edge labels). The dispatch hop uses materialized `[:OVERRIDES]`; terminal hops use stored `DECLARES_CLIENT`, `DECLARES_PRODUCER`, or `EXPOSES`. + +| Dot-key | Recipe | +| --- | --- | +| `OVERRIDDEN_BY` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY'])` — same overrider method ids as `neighbors(['{id}'],'in',['OVERRIDES'])` on a declaration method | +| `OVERRIDDEN_BY.DECLARES_CLIENT` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_CLIENT'])` — clients on all overriders (`via_id` = overrider method in attrs) | +| `OVERRIDDEN_BY.DECLARES_PRODUCER` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_PRODUCER'])` | +| `OVERRIDDEN_BY.EXPOSES` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.EXPOSES'])` | + +Do not mix `DECLARES.*` and `OVERRIDDEN_BY.*` in one `edge_types` list on a single origin id. + ## Graph storage (not MCP `neighbors` edge_types) ### `UnresolvedCallSite` + `UNRESOLVED_AT` (ontology 15 / CALLS-NOISE PR-3) diff --git a/kuzu_queries.py b/kuzu_queries.py index 6a9ba0b..fe3611b 100644 --- a/kuzu_queries.py +++ b/kuzu_queries.py @@ -35,6 +35,15 @@ ) _MEMBER_EDGE_COMPOSED_REL_BY_KEY: dict[str, str] = dict(_MEMBER_EDGE_COMPOSED_REL_MAP) +_OVERRIDE_AXIS_COMPOSED_REL_MAP: tuple[tuple[str, str | None], ...] = ( + ("OVERRIDDEN_BY", None), + ("OVERRIDDEN_BY.DECLARES_CLIENT", "DECLARES_CLIENT"), + ("OVERRIDDEN_BY.DECLARES_PRODUCER", "DECLARES_PRODUCER"), + ("OVERRIDDEN_BY.EXPOSES", "EXPOSES"), +) +_OVERRIDE_AXIS_COMPOSED_REL_BY_KEY: dict[str, str | None] = dict(_OVERRIDE_AXIS_COMPOSED_REL_MAP) +OVERRIDE_AXIS_COMPOSED_EDGE_TYPES: frozenset[str] = frozenset(_OVERRIDE_AXIS_COMPOSED_REL_BY_KEY) + def _coerce_id_list(raw: Any) -> list[str]: """Normalize Kuzu ``collect(DISTINCT ...)`` list results to string ids.""" @@ -667,6 +676,34 @@ def member_edge_traversal_for(self, type_id: str, composed_key: str) -> list[dic {"id": type_id, "rel": rel}, ) + def override_axis_traversal_for(self, method_id: str, composed_key: str) -> list[dict[str, Any]]: + """Override-axis composed traversal for a method Symbol (neighbors dot-key path). + + Uses stored ``[:OVERRIDES]`` for the dispatch hop (aligned with ``override_axis_rollup_for`` + overrider ids). Base key returns overrider method ids only; composed keys return terminal + rows with full edge attr projection plus ``via_id`` (overrider method id). + """ + rel = _OVERRIDE_AXIS_COMPOSED_REL_BY_KEY.get(composed_key) + if rel is None and composed_key != "OVERRIDDEN_BY": + return [] + if rel is None: + return self._rows( + "MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol) " + "RETURN mover.id AS other_id", + {"id": method_id}, + ) + return self._rows( + "MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol)-[e]->(term) " + "WHERE label(e) = $rel " + "RETURN mover.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": method_id, "rel": rel}, + ) + def count_calls_for_symbol(self, origin_id: str, *, direction: Literal["in", "out"]) -> int: """Count CALLS edges incident on a Symbol (hints / diagnostics).""" if direction == "out": diff --git a/mcp_hints.py b/mcp_hints.py index 2f6bf2f..857e3b4 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -19,7 +19,8 @@ "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. Describe-time type rollup hints may recommend " - "DECLARES.* dot-keys for neighbors(); empty neighbors structural hints never use " + "DECLARES.* and OVERRIDDEN_BY.* dot-keys for neighbors() on matching Symbol origins; " + "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. On neighbors with " "edge_types=['CALLS'] only, optional edge_filter projects the ordered CALLS stream " @@ -40,18 +41,15 @@ TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS = ( "producers via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_PRODUCER'])" ) -TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'in',['OVERRIDES'])" +TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY'])" TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS = ( - "clients in overriders: neighbors(['{id}'],'in',['OVERRIDES']) " - "then neighbors(overrider_ids,'out',['DECLARES_CLIENT'])" + "clients in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_CLIENT'])" ) TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS = ( - "producers in overriders: neighbors(['{id}'],'in',['OVERRIDES']) " - "then neighbors(overrider_ids,'out',['DECLARES_PRODUCER'])" + "producers in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_PRODUCER'])" ) TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS = ( - "routes in overriders: neighbors(['{id}'],'in',['OVERRIDES']) " - "then neighbors(overrider_ids,'out',['EXPOSES'])" + "routes in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.EXPOSES'])" ) TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT = "outbound client: neighbors(['{id}'],'out',['DECLARES_CLIENT'])" TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER = "outbound producer: neighbors(['{id}'],'out',['DECLARES_PRODUCER'])" diff --git a/mcp_v2.py b/mcp_v2.py index 7cb9b48..f7d7d2a 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -30,15 +30,14 @@ from index_common import SBERT_MODEL from java_codebase_rag.config import resolved_sbert_model_for_process_env from java_ontology import EDGE_SCHEMA, ResolveReason -from kuzu_queries import KuzuGraph +from kuzu_queries import KuzuGraph, OVERRIDE_AXIS_COMPOSED_EDGE_TYPES from mcp_hints import MCP_HINTS_FIELD_DESCRIPTION, generate_hints from search_lancedb import TABLES, run_search DeclarationSymbolKind = Literal["class", "interface", "enum", "record", "annotation", "method", "constructor"] -# 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. +# Stored graph edge labels for one-hop neighbors. Composed DECLARES.* and OVERRIDDEN_BY.* +# dot-keys are separate ComposedEdgeType literals (2-hop traversal). Stored OVERRIDES is an EdgeType. EdgeType = Literal[ "EXTENDS", "IMPLEMENTS", @@ -57,11 +56,19 @@ "DECLARES.DECLARES_CLIENT", "DECLARES.DECLARES_PRODUCER", "DECLARES.EXPOSES", + "OVERRIDDEN_BY", + "OVERRIDDEN_BY.DECLARES_CLIENT", + "OVERRIDDEN_BY.DECLARES_PRODUCER", + "OVERRIDDEN_BY.EXPOSES", ] NeighborEdgeType = EdgeType | ComposedEdgeType _COMPOSED_EDGE_TYPES = frozenset(get_args(ComposedEdgeType)) +_MEMBER_COMPOSED_EDGE_TYPES = frozenset( + k for k in _COMPOSED_EDGE_TYPES if k.startswith("DECLARES.") +) +_OVERRIDE_COMPOSED_EDGE_TYPES = OVERRIDE_AXIS_COMPOSED_EDGE_TYPES _NEIGHBOR_EDGE_TYPES_ADAPTER = TypeAdapter( Annotated[ @@ -412,14 +419,15 @@ class NodeRecord(BaseModel): "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; navigable via neighbors for type " - "Symbol origins (`direction=\"out\"` only). For method Symbols, may include " + "Symbol origins (`direction=\"out\"` only). For non-static 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 OVERRIDDEN_BY* " - "virtual keys are not valid neighbors(edge_types=…) arguments. The stored relationship " - "label `OVERRIDES` **is** a valid EdgeType for neighbors." + "`OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES` (stored `[:OVERRIDES]` " + "dispatch hop, then terminal edges; navigable via neighbors for method Symbol origins, " + "`direction=\"out\"` only; composed results include `via_id` in attrs). 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). The stored relationship label `OVERRIDES` **is** also a valid " + "EdgeType for one-hop neighbors (`direction=\"in\"` from declaration toward overriders)." ), ) @@ -1628,6 +1636,23 @@ def _neighbors_calls_for_origin( return edges[offset : offset + limit] +def _composed_axis_origin_error( + *, + symbol_kind: str, + modifiers: list[str] | None, + declares_composed: list[str], + override_composed: list[str], +) -> str | None: + """Fail-fast origin gate for composed DECLARES.* vs OVERRIDDEN_BY.* families.""" + if declares_composed and symbol_kind not in _TYPE_SYMBOL_KINDS_FOR_EDGE_ROLLUP: + return f"Composed edge types ({declares_composed[0]}) require a type Symbol origin" + if override_composed: + mods = modifiers or [] + if symbol_kind not in _METHOD_SYMBOL_KINDS_FOR_OVERRIDE_ROLLUP or "static" in mods: + return f"Composed edge types ({override_composed[0]}) require a method Symbol origin" + return None + + @validate_call(config={"arbitrary_types_allowed": True}) def neighbors_v2( ids: str | list[str], @@ -1648,6 +1673,9 @@ def neighbors_v2( 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] + declares_composed = [k for k in composed_keys if k in _MEMBER_COMPOSED_EDGE_TYPES] + override_composed = [k for k in composed_keys if k in _OVERRIDE_COMPOSED_EDGE_TYPES] + ordered_composed = declares_composed + override_composed g = graph or KuzuGraph.get() try: raw_filter = _coerce_filter(filter) @@ -1735,24 +1763,33 @@ def neighbors_v2( calls_row_count = g.count_calls_for_symbol(origins[0], direction=direction) for origin_id in origins: origin_kind = _resolve_node_kind(g, origin_id) - if composed_keys: + if ordered_composed: if origin_kind != "symbol": + first_key = ordered_composed[0] + axis_msg = ( + f"Composed edge types ({first_key}) require a method Symbol origin" + if first_key in _OVERRIDE_COMPOSED_EDGE_TYPES + else f"Composed edge types ({first_key}) require a type Symbol origin" + ) return NeighborsOutput( success=False, - message=( - f"Composed edge types ({composed_keys[0]}) require a type Symbol origin" - ), + message=axis_msg, 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: + mods_raw = (origin_row or {}).get("modifiers") + mods = mods_raw if isinstance(mods_raw, list) else None + if err := _composed_axis_origin_error( + symbol_kind=sym_kind, + modifiers=mods, + declares_composed=declares_composed, + override_composed=override_composed, + ): return NeighborsOutput( success=False, - message=( - f"Composed edge types ({composed_keys[0]}) require a type Symbol origin" - ), + message=err, hints=[], requested_edge_types=requested_edge_types, ) @@ -1842,8 +1879,12 @@ def neighbors_v2( attrs=_neighbor_edge_attrs(row), ) ) - for composed_key in composed_keys: - for row in g.member_edge_traversal_for(origin_id, composed_key): + for composed_key in ordered_composed: + if composed_key in _MEMBER_COMPOSED_EDGE_TYPES: + traversal_rows = g.member_edge_traversal_for(origin_id, composed_key) + else: + traversal_rows = g.override_axis_traversal_for(origin_id, composed_key) + for row in traversal_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) @@ -1856,13 +1897,17 @@ def neighbors_v2( ) if not _node_matches_filter(other_kind, other_rec, nf): continue + if composed_key == "OVERRIDDEN_BY": + edge_attrs: dict[str, Any] = {} + else: + edge_attrs = _neighbor_edge_attrs(row) 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), + attrs=edge_attrs, ) ) if use_calls_path and len(origins) > 1: diff --git a/scripts/generate_edge_navigation.py b/scripts/generate_edge_navigation.py index 52c6992..f836f94 100644 --- a/scripts/generate_edge_navigation.py +++ b/scripts/generate_edge_navigation.py @@ -18,6 +18,21 @@ _COMPOSED_MEMBER_EDGE_NAMES = frozenset({"EXPOSES", "DECLARES_CLIENT", "DECLARES_PRODUCER"}) +_OVERRIDE_AXIS_APPENDIX = """ +## Override-axis composed keys (method Symbol, `direction="out"` only) + +Virtual `edge_summary` / `neighbors` dot-keys (not stored graph edge labels). The dispatch hop uses materialized `[:OVERRIDES]`; terminal hops use stored `DECLARES_CLIENT`, `DECLARES_PRODUCER`, or `EXPOSES`. + +| Dot-key | Recipe | +| --- | --- | +| `OVERRIDDEN_BY` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY'])` — same overrider method ids as `neighbors(['{id}'],'in',['OVERRIDES'])` on a declaration method | +| `OVERRIDDEN_BY.DECLARES_CLIENT` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_CLIENT'])` — clients on all overriders (`via_id` = overrider method in attrs) | +| `OVERRIDDEN_BY.DECLARES_PRODUCER` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_PRODUCER'])` | +| `OVERRIDDEN_BY.EXPOSES` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.EXPOSES'])` | + +Do not mix `DECLARES.*` and `OVERRIDDEN_BY.*` in one `edge_types` list on a single origin id. +""" + _GRAPH_STORAGE_APPENDIX = """ ## Graph storage (not MCP `neighbors` edge_types) @@ -93,6 +108,7 @@ def generate_markdown() -> str: parts.append("") for spec in EDGE_SCHEMA.values(): parts.extend(_render_edge(spec)) + parts.append(_OVERRIDE_AXIS_APPENDIX.rstrip()) parts.append(_GRAPH_STORAGE_APPENDIX.rstrip()) return "\n".join(parts).rstrip() + "\n" diff --git a/server.py b/server.py index 74fe6ea..2e50bce 100644 --- a/server.py +++ b/server.py @@ -420,8 +420,9 @@ async def find( "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\", ...]). " + "Override-axis virtual keys are navigable via neighbors on non-static method Symbol origins " + "(out only; composed keys include via_id in attrs). The stored `OVERRIDES` relationship " + "is also a normal edge label (e.g. direction in from declaration toward overriders). " "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, " "prefer `resolve(identifier=…, hint_kind='symbol')` first, then `describe(id=…)` on the chosen node. " @@ -451,7 +452,9 @@ async def describe( description=( "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. " + "for 2-hop member rollups; method Symbol origins may pass OVERRIDDEN_BY, OVERRIDDEN_BY.DECLARES_CLIENT, " + "OVERRIDDEN_BY.DECLARES_PRODUCER, OVERRIDDEN_BY.EXPOSES for override-axis rollups — out only, via_id in " + "attrs on composed keys). " "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). " "Optional `edge_filter` requires edge_types=['CALLS'] only (no composed dot-keys or extra stored " @@ -472,8 +475,8 @@ async def neighbors( 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)" + "and/or composed DECLARES.DECLARES_* (type Symbol origin, out only) or OVERRIDDEN_BY* " + "(non-static method Symbol origin, out only)" ), ), limit: int = Field( diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index b48dec1..dac4eb0 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -208,6 +208,15 @@ def test_hints_describe_method_clients_in_overriders_emits(kuzu_graph) -> None: assert want in out.hints +def test_hints_describe_method_overridden_by_declares_client_emits_dot_key(kuzu_graph) -> None: + mid = _interface_method_with_override_rollups(kuzu_graph) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS.format(id=mid) + assert want in out.hints + assert "OVERRIDDEN_BY.DECLARES_CLIENT" in want + + def test_hints_describe_method_producers_in_overriders_emits(override_axis_graph: KuzuGraph) -> None: rows = override_axis_graph._rows( # noqa: SLF001 "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " diff --git a/tests/test_mcp_v2_compose.py b/tests/test_mcp_v2_compose.py index 874d9d5..091f1a1 100644 --- a/tests/test_mcp_v2_compose.py +++ b/tests/test_mcp_v2_compose.py @@ -4,7 +4,6 @@ from typing import Any import pytest -from pydantic import ValidationError from _builders import build_kuzu_to from kuzu_queries import KuzuGraph @@ -384,8 +383,8 @@ def test_describe_interface_method_with_annotated_impl_emits_rollup(kuzu_graph) es = out.record.edge_summary assert es.get("OVERRIDDEN_BY") == {"in": 0, "out": want_ob} assert es.get("OVERRIDDEN_BY.DECLARES_CLIENT") == {"in": 0, "out": want_dc} - with pytest.raises(ValidationError): - neighbors_v2(mid, direction="out", edge_types=["OVERRIDDEN_BY"], graph=kuzu_graph) + out_ob = neighbors_v2(mid, direction="out", edge_types=["OVERRIDDEN_BY"], graph=kuzu_graph) + assert out_ob.success is True def test_describe_concrete_override_emits_overrides_rollup(kuzu_graph) -> None: @@ -560,14 +559,248 @@ def test_neighbors_edge_type_adapter_accepts_overrides() -> None: _NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python(["OVERRIDES"]) -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=["OVERRIDDEN_BY.DECLARES_CLIENT"], graph=kuzu_graph +_OVERRIDE_AXIS_COMPOSED_KEYS = ( + "OVERRIDDEN_BY", + "OVERRIDDEN_BY.DECLARES_CLIENT", + "OVERRIDDEN_BY.DECLARES_PRODUCER", + "OVERRIDDEN_BY.EXPOSES", +) + + +def _request_assignment_method_id(graph: KuzuGraph) -> str: + rows = graph._rows( # noqa: SLF001 + "MATCH (iface:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'requestAssignment' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "com.bank.chat.engine.assign.ChatAssignmentPort"}, + ) + assert rows + return str(rows[0]["id"]) + + +def test_neighbors_accepts_overridden_by_dot_keys() -> None: + for key in _OVERRIDE_AXIS_COMPOSED_KEYS: + _NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python([key]) + + +def test_neighbors_overridden_by_dot_key_returns_overriders(kuzu_graph: KuzuGraph) -> None: + mid = _request_assignment_method_id(kuzu_graph) + want = sorted(_dispatch_down_override_method_ids(kuzu_graph, mid)) + assert want + out_virtual = neighbors_v2(mid, direction="out", edge_types=["OVERRIDDEN_BY"], graph=kuzu_graph) + out_stored = neighbors_v2(mid, direction="in", edge_types=["OVERRIDES"], graph=kuzu_graph) + assert out_virtual.success is True + assert out_stored.success is True + got_virtual = sorted({e.other.id for e in out_virtual.results}) + got_stored = sorted({e.other.id for e in out_stored.results}) + assert got_virtual == want + assert got_stored == want + assert all(e.edge_type == "OVERRIDDEN_BY" for e in out_virtual.results) + assert all(e.other.kind == "symbol" for e in out_virtual.results) + assert all("via_id" not in e.attrs for e in out_virtual.results) + + +def test_neighbors_overridden_by_dot_key_declares_client(kuzu_graph: KuzuGraph) -> None: + mid = _request_assignment_method_id(kuzu_graph) + out = neighbors_v2( + mid, direction="out", edge_types=["OVERRIDDEN_BY.DECLARES_CLIENT"], graph=kuzu_graph, limit=500 + ) + assert out.success is True + assert len(out.results) >= 1 + assert all(e.edge_type == "OVERRIDDEN_BY.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_overridden_by_dot_key_declares_producer(override_axis_graph: KuzuGraph) -> None: + rows = override_axis_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'publish' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "orolla.abstractproducer.AbstractProducerApi"}, + ) + assert rows + mid = str(rows[0]["id"]) + out = neighbors_v2( + mid, + direction="out", + edge_types=["OVERRIDDEN_BY.DECLARES_PRODUCER"], + graph=override_axis_graph, + limit=500, + ) + assert out.success is True + assert len(out.results) >= 1 + assert all(e.edge_type == "OVERRIDDEN_BY.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_overridden_by_dot_key_exposes(override_axis_graph: KuzuGraph) -> None: + rows = override_axis_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'handle' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "orolla.abstractroute.AbstractApi"}, + ) + assert rows + mid = str(rows[0]["id"]) + out = neighbors_v2( + mid, + direction="out", + edge_types=["OVERRIDDEN_BY.EXPOSES"], + graph=override_axis_graph, + limit=500, + ) + assert out.success is True + assert len(out.results) >= 1 + assert all(e.edge_type == "OVERRIDDEN_BY.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_overridden_by_dot_key_count_matches_edge_summary(kuzu_graph: KuzuGraph) -> None: + mid = _request_assignment_method_id(kuzu_graph) + d = describe_v2(mid, graph=kuzu_graph) + n = neighbors_v2( + mid, + direction="out", + edge_types=["OVERRIDDEN_BY.DECLARES_CLIENT"], + graph=kuzu_graph, + limit=500, + ) + assert d.success and d.record and d.record.edge_summary + summary = d.record.edge_summary.get("OVERRIDDEN_BY.DECLARES_CLIENT") + assert summary is not None + assert n.success is True + assert len(n.results) == summary["out"] + + +def test_neighbors_overridden_by_dot_key_type_origin_rejected(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=["OVERRIDDEN_BY.DECLARES_CLIENT"], graph=kuzu_graph + ) + assert out.success is False + assert out.message is not None + assert "method Symbol origin" in out.message + + +def test_neighbors_mixed_composed_families_on_type_rejected(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", "OVERRIDDEN_BY.DECLARES_CLIENT"], + graph=kuzu_graph, + ) + assert out.success is False + assert out.message is not None + assert "method Symbol origin" in out.message + + +def test_neighbors_mixed_composed_families_on_method_rejected(kuzu_graph: KuzuGraph) -> None: + mid = _request_assignment_method_id(kuzu_graph) + out = neighbors_v2( + mid, + direction="out", + edge_types=["DECLARES.DECLARES_CLIENT", "OVERRIDDEN_BY.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_overridden_by_dot_key_static_method_rejected(kuzu_graph: KuzuGraph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol) " + "WHERE m.kind = 'method' AND list_contains(COALESCE(m.modifiers, []), 'static') " + "RETURN m.id AS id LIMIT 1", + ) + assert rows + mid = str(rows[0]["id"]) + out = neighbors_v2( + mid, direction="out", edge_types=["OVERRIDDEN_BY.DECLARES_CLIENT"], graph=kuzu_graph + ) + assert out.success is False + assert out.message is not None + assert "method Symbol origin" in out.message + + +def test_neighbors_overridden_by_dot_key_inbound_rejected(kuzu_graph: KuzuGraph) -> None: + mid = _request_assignment_method_id(kuzu_graph) + out = neighbors_v2( + mid, direction="in", edge_types=["OVERRIDDEN_BY.DECLARES_CLIENT"], graph=kuzu_graph + ) + assert out.success is False + assert out.message is not None + assert 'direction="out"' in out.message + + +def _override_axis_smoke_method_id(graph: KuzuGraph, *, fqn: str, method_name: str) -> str: + rows = graph._rows( # noqa: SLF001 + "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = $name " + "RETURN m.id AS id LIMIT 1", + {"fqn": fqn, "name": method_name}, + ) + assert rows + return str(rows[0]["id"]) + + +def _override_parity_graph_method_pairs( + composed_key: str, + kuzu_graph: KuzuGraph, + override_axis_graph: KuzuGraph, +) -> list[tuple[KuzuGraph, str]]: + pairs: list[tuple[KuzuGraph, str]] = [(kuzu_graph, _request_assignment_method_id(kuzu_graph))] + if composed_key in ("OVERRIDDEN_BY", "OVERRIDDEN_BY.EXPOSES"): + pairs.append( + ( + override_axis_graph, + _override_axis_smoke_method_id( + override_axis_graph, + fqn="orolla.abstractroute.AbstractApi", + method_name="handle", + ), + ) + ) + if composed_key == "OVERRIDDEN_BY.DECLARES_PRODUCER": + pairs.append( + ( + override_axis_graph, + _override_axis_smoke_method_id( + override_axis_graph, + fqn="orolla.abstractproducer.AbstractProducerApi", + method_name="publish", + ), + ) + ) + return pairs + + +@pytest.mark.parametrize("composed_key", _OVERRIDE_AXIS_COMPOSED_KEYS) +def test_neighbors_overridden_by_rollup_traversal_parity_blocking( + kuzu_graph: KuzuGraph, + override_axis_graph: KuzuGraph, + composed_key: str, +) -> None: + checked = False + for graph, mid in _override_parity_graph_method_pairs( + composed_key, kuzu_graph, override_axis_graph + ): + d = describe_v2(mid, graph=graph) + n = neighbors_v2( + mid, direction="out", edge_types=[composed_key], graph=graph, limit=5000 ) + assert d.success and d.record and d.record.edge_summary + summary = d.record.edge_summary.get(composed_key) + if summary is None or int(summary.get("out", 0) or 0) == 0: + continue + checked = True + assert n.success is True + assert len(n.results) == summary["out"] + assert checked, f"no fixture method with non-zero {composed_key} rollup" def _type_id_with_composed_key(kuzu_graph: KuzuGraph, rel: str, composed_key: str) -> tuple[str, int]: From bb74c20219d7e7d258b488f699b6989c563d9f2c Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 20 May 2026 11:26:52 +0300 Subject: [PATCH 2/2] address PR review: align rollup dispatch with stored OVERRIDES override_axis_rollup_for now uses the same [:OVERRIDES] hop as neighbors traversal; sharpen static/constructor gate messages; document parity and pagination; add dispatch alignment regression test. Co-authored-by: Cursor --- docs/AGENT-GUIDE.md | 6 ++- docs/EDGE-NAVIGATION.md | 2 + kuzu_queries.py | 58 ++++++++++++++--------------- mcp_v2.py | 15 +++++++- scripts/generate_edge_navigation.py | 2 + tests/test_mcp_v2_compose.py | 41 +++++++++++++++++++- 6 files changed, 91 insertions(+), 33 deletions(-) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index cd52b97..7967943 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -97,6 +97,10 @@ Use these strings **verbatim** in `neighbors(..., edge_types=[...])`. Do not mix `DECLARES.*` and `OVERRIDDEN_BY.*` in one `edge_types` list on a single origin id — the handler rejects the whole request (only one axis applies per node). +`describe` `edge_summary` counts for `OVERRIDDEN_BY*` use the same stored `[:OVERRIDES]` dispatch hop as `neighbors` (ontology **13+** graphs with materialized override edges). Rebuild the index if counts look wrong or dot-key walks return fewer rows than advertised. + +**Pagination:** default `neighbors` `limit=25` slices the merged flat + composed edge list. When `edge_summary` shows a large `out` count for a composed key, raise `limit` (and use `offset`) or issue separate calls per key. + ### Argument shapes #### JSON, not stringified JSON @@ -212,7 +216,7 @@ Full node + `edge_summary`. Args: `id` (any kind) or `fqn` (symbol only; `id` wi - **Stored keys** — counts for edges that exist in the graph. - **Type symbols** (`class`, `interface`, `enum`, `record`, `annotation`) may add composed keys `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` — navigable via `neighbors` with those dot-keys (`out` only). -- **Method symbols** may add virtual keys `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_*`, `OVERRIDDEN_BY.EXPOSES` (navigable via `neighbors` on method origins, `out` only), plus an **`OVERRIDES`** row merging stored `[:OVERRIDES]` counts with a dispatch-up rollup (`in`/`out` per direction uses `max` of stored vs rollup). Use `neighbors(..., ["OVERRIDDEN_BY.DECLARES_CLIENT"])` (etc.) or one-hop `neighbors(..., ["OVERRIDES"])`. Static methods and constructors do not get override-axis keys. +- **Method symbols** may add virtual keys `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_*`, `OVERRIDDEN_BY.EXPOSES` (navigable via `neighbors` on non-static method origins, `out` only), plus an **`OVERRIDES`** row merging stored `[:OVERRIDES]` incident counts with the rollup dispatch-up count (`max` per direction). Rollup and dot-key traversal both use stored `[:OVERRIDES]` for the dispatch hop. Static methods and constructors do not get override-axis keys. Composed counts are **edge rows**, not distinct methods; `count > 0` means "there is something to walk". diff --git a/docs/EDGE-NAVIGATION.md b/docs/EDGE-NAVIGATION.md index d71bc1d..291aeca 100644 --- a/docs/EDGE-NAVIGATION.md +++ b/docs/EDGE-NAVIGATION.md @@ -267,6 +267,8 @@ Virtual `edge_summary` / `neighbors` dot-keys (not stored graph edge labels). Th Do not mix `DECLARES.*` and `OVERRIDDEN_BY.*` in one `edge_types` list on a single origin id. +`describe` `edge_summary` and `neighbors` share the stored `[:OVERRIDES]` dispatch hop (requires ontology **13+** materialized edges). Default `limit=25` may truncate large composed result sets — raise `limit` when `out` counts are high. + ## Graph storage (not MCP `neighbors` edge_types) ### `UnresolvedCallSite` + `UNRESOLVED_AT` (ontology 15 / CALLS-NOISE PR-3) diff --git a/kuzu_queries.py b/kuzu_queries.py index fe3611b..67c9e16 100644 --- a/kuzu_queries.py +++ b/kuzu_queries.py @@ -893,12 +893,31 @@ def _edge_row_count_from_method_ids(self, method_ids: list[str], rel: str) -> in total += int(rows[0].get("n") or 0) if rows else 0 return total + def _override_impl_ids_from_stored(self, method_id: str) -> list[str]: + """Overrider method ids for a declaration method (stored ``[:OVERRIDES]`` in-hop).""" + rows = self._rows( + "MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol) " + "RETURN collect(DISTINCT mover.id) AS ids", + {"id": method_id}, + ) + return list(dict.fromkeys(_coerce_id_list(rows[0].get("ids") if rows else None))) + + def _override_decl_ids_from_stored(self, method_id: str) -> list[str]: + """Declaration method ids overridden by a concrete method (stored ``[:OVERRIDES]`` out-hop).""" + rows = self._rows( + "MATCH (m:Symbol {id: $id})-[:OVERRIDES]->(decl:Symbol) " + "RETURN collect(DISTINCT decl.id) AS ids", + {"id": method_id}, + ) + return list(dict.fromkeys(_coerce_id_list(rows[0].get("ids") if rows else None))) + def override_axis_rollup_for(self, method_id: str) -> dict[str, dict[str, int]]: """Dispatch-axis composed keys for method Symbols (describe-time only). - Uses one-hop ``IMPLEMENTS`` / ``EXTENDS`` class edges plus ``Symbol.signature`` - equality. Omits keys with zero counts (same convention as ``edge_counts_for``). - Returns ``{}`` for non-methods, constructors (caller should skip), and static methods. + Dispatch hop uses materialized ``[:OVERRIDES]`` (same as ``override_axis_traversal_for`` / + ``neighbors`` dot-keys). Terminal composed counts sum outgoing edges from overrider + methods. Omits keys with zero counts. Returns ``{}`` for non-methods, constructors, + and static methods. """ params = {"id": method_id} gate = self._rows( @@ -913,41 +932,22 @@ def override_axis_rollup_for(self, method_id: str) -> dict[str, dict[str, int]]: rollup: dict[str, dict[str, int]] = {} - down_rows = self._rows( - "MATCH (m:Symbol {id: $id})<-[:DECLARES]-(t:Symbol) " - "MATCH (impl:Symbol)-[:IMPLEMENTS|EXTENDS]->(t) " - "MATCH (impl)-[:DECLARES]->(mover:Symbol) " - "WHERE mover.signature = m.signature AND mover.id <> m.id " - "RETURN collect(DISTINCT mover.id) AS ids", - params, - ) - impl_ids = _coerce_id_list(down_rows[0].get("ids") if down_rows else None) - + impl_ids = self._override_impl_ids_from_stored(method_id) if impl_ids: - distinct_impl = list(dict.fromkeys(impl_ids)) - rollup["OVERRIDDEN_BY"] = {"in": 0, "out": len(distinct_impl)} - n_dc = self._edge_row_count_from_method_ids(distinct_impl, "DECLARES_CLIENT") + rollup["OVERRIDDEN_BY"] = {"in": 0, "out": len(impl_ids)} + n_dc = self._edge_row_count_from_method_ids(impl_ids, "DECLARES_CLIENT") if n_dc > 0: rollup["OVERRIDDEN_BY.DECLARES_CLIENT"] = {"in": 0, "out": n_dc} - n_dp = self._edge_row_count_from_method_ids(distinct_impl, "DECLARES_PRODUCER") + n_dp = self._edge_row_count_from_method_ids(impl_ids, "DECLARES_PRODUCER") if n_dp > 0: rollup["OVERRIDDEN_BY.DECLARES_PRODUCER"] = {"in": 0, "out": n_dp} - n_ex = self._edge_row_count_from_method_ids(distinct_impl, "EXPOSES") + n_ex = self._edge_row_count_from_method_ids(impl_ids, "EXPOSES") if n_ex > 0: rollup["OVERRIDDEN_BY.EXPOSES"] = {"in": 0, "out": n_ex} - up_rows = self._rows( - "MATCH (m:Symbol {id: $id})<-[:DECLARES]-(impl:Symbol) " - "MATCH (impl)-[:IMPLEMENTS|EXTENDS]->(parent:Symbol) " - "MATCH (parent)-[:DECLARES]->(decl_m:Symbol) " - "WHERE decl_m.signature = m.signature AND decl_m.id <> m.id " - "RETURN collect(DISTINCT decl_m.id) AS ids", - params, - ) - decl_ids = _coerce_id_list(up_rows[0].get("ids") if up_rows else None) + decl_ids = self._override_decl_ids_from_stored(method_id) if decl_ids: - distinct_decl = list(dict.fromkeys(decl_ids)) - rollup["OVERRIDES"] = {"in": 0, "out": len(distinct_decl)} + rollup["OVERRIDES"] = {"in": 0, "out": len(decl_ids)} return rollup diff --git a/mcp_v2.py b/mcp_v2.py index f7d7d2a..8e54385 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -1647,9 +1647,20 @@ def _composed_axis_origin_error( if declares_composed and symbol_kind not in _TYPE_SYMBOL_KINDS_FOR_EDGE_ROLLUP: return f"Composed edge types ({declares_composed[0]}) require a type Symbol origin" if override_composed: + key = override_composed[0] mods = modifiers or [] - if symbol_kind not in _METHOD_SYMBOL_KINDS_FOR_OVERRIDE_ROLLUP or "static" in mods: - return f"Composed edge types ({override_composed[0]}) require a method Symbol origin" + if symbol_kind == "constructor": + return ( + f"Composed edge types ({key}) require a non-static method Symbol origin " + "(constructors are not supported)" + ) + if symbol_kind not in _METHOD_SYMBOL_KINDS_FOR_OVERRIDE_ROLLUP: + return f"Composed edge types ({key}) require a method Symbol origin" + if "static" in mods: + return ( + f"Composed edge types ({key}) require a non-static method Symbol origin " + "(static methods are not supported)" + ) return None diff --git a/scripts/generate_edge_navigation.py b/scripts/generate_edge_navigation.py index f836f94..cbeba91 100644 --- a/scripts/generate_edge_navigation.py +++ b/scripts/generate_edge_navigation.py @@ -31,6 +31,8 @@ | `OVERRIDDEN_BY.EXPOSES` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.EXPOSES'])` | Do not mix `DECLARES.*` and `OVERRIDDEN_BY.*` in one `edge_types` list on a single origin id. + +`describe` `edge_summary` and `neighbors` share the stored `[:OVERRIDES]` dispatch hop (requires ontology **13+** materialized edges). Default `limit=25` may truncate large composed result sets — raise `limit` when `out` counts are high. """ _GRAPH_STORAGE_APPENDIX = """ diff --git a/tests/test_mcp_v2_compose.py b/tests/test_mcp_v2_compose.py index 091f1a1..6c06092 100644 --- a/tests/test_mcp_v2_compose.py +++ b/tests/test_mcp_v2_compose.py @@ -578,6 +578,28 @@ def _request_assignment_method_id(graph: KuzuGraph) -> str: return str(rows[0]["id"]) +def test_override_axis_rollup_dispatch_matches_signature_walk_on_fixtures( + kuzu_graph: KuzuGraph, + override_axis_graph: KuzuGraph, +) -> None: + """Guard: stored [:OVERRIDES] dispatch ids stay aligned with legacy signature walk on fixtures.""" + cases = [ + (kuzu_graph, _request_assignment_method_id(kuzu_graph)), + ( + override_axis_graph, + _override_axis_smoke_method_id( + override_axis_graph, + fqn="orolla.abstractroute.AbstractApi", + method_name="handle", + ), + ), + ] + for graph, mid in cases: + stored = sorted(graph._override_impl_ids_from_stored(mid)) # noqa: SLF001 + signature = sorted(_dispatch_down_override_method_ids(graph, mid)) + assert stored == signature + + def test_neighbors_accepts_overridden_by_dot_keys() -> None: for key in _OVERRIDE_AXIS_COMPOSED_KEYS: _NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python([key]) @@ -724,7 +746,23 @@ def test_neighbors_overridden_by_dot_key_static_method_rejected(kuzu_graph: Kuzu ) assert out.success is False assert out.message is not None - assert "method Symbol origin" in out.message + assert "non-static" in out.message + + +def test_neighbors_overridden_by_dot_key_constructor_rejected(kuzu_graph: KuzuGraph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol)-[:DECLARES]->(c:Symbol) " + "WHERE c.kind = 'constructor' " + "RETURN c.id AS id LIMIT 1", + ) + assert rows + cid = str(rows[0]["id"]) + out = neighbors_v2( + cid, direction="out", edge_types=["OVERRIDDEN_BY.DECLARES_CLIENT"], graph=kuzu_graph + ) + assert out.success is False + assert out.message is not None + assert "constructor" in out.message def test_neighbors_overridden_by_dot_key_inbound_rejected(kuzu_graph: KuzuGraph) -> None: @@ -753,6 +791,7 @@ def _override_parity_graph_method_pairs( kuzu_graph: KuzuGraph, override_axis_graph: KuzuGraph, ) -> list[tuple[KuzuGraph, str]]: + # OVERRIDDEN_BY.DECLARES_CLIENT: bank-chat only — smoke corpus has no DECLARES_CLIENT on overriders. pairs: list[tuple[KuzuGraph, str]] = [(kuzu_graph, _request_assignment_method_id(kuzu_graph))] if composed_key in ("OVERRIDDEN_BY", "OVERRIDDEN_BY.EXPOSES"): pairs.append(